Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@ const icon = createPrivacyThingLogo({
registry. `createPrivacyThingLogo()` creates and configures an element in an
optional owner document.

### Event commands

Dispatch `privacy-thing:command` directly on a logo element to control its
animated Thing without replacing the SVG. The package helper keeps the event
name and payload typed:

```ts
import {
dispatchPrivacyThingCommand,
type PrivacyThingLogoElement,
} from "@privacy-thing/brand";

const logo = document.querySelector<PrivacyThingLogoElement>(
"privacy-thing-logo",
);

if (logo) {
dispatchPrivacyThingCommand(logo, {
type: "look",
direction: "north-east",
});

dispatchPrivacyThingCommand(logo, { type: "reset" });
}
```

`look` accepts `idle` and all eight directions. It uses the configured pose
transition and can be superseded by later pointer interaction. `reset` returns
to the declarative `pose`. A configured `zz` scene remains authoritative and
ignores look commands.

## Styling and composition

- The complete logo uses `currentColor`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@privacy-thing/brand",
"version": "0.1.1",
"version": "0.1.2",
"description": "Framework-agnostic Privacy Thing logos and animated Thing runtime.",
"type": "module",
"license": "UNLICENSED",
Expand Down
64 changes: 52 additions & 12 deletions src/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const HEAD_PATH_POINTS = {

const BOOP_CURVE_TENSION = 0.276142;
const BOOP_DIAGONAL_CONTACT_DEPTH = 3.2;
const BOOP_DIAGONAL_CONTACT_TENSION_SCALE = 0.62;
const BOOP_DIAGONAL_OPPOSITE_BULGE = 14;

const formatThingPoint = ([x, y]: ThingPoint) =>
`${Number(x.toFixed(3))} ${Number(y.toFixed(3))}`;
Expand All @@ -95,6 +97,7 @@ const distortBoopPoint = (
direction: Readonly<{ x: number; y: number }>,
intensity: number,
contactDepth: number,
oppositeBulge: number,
): ThingPoint => {
const normalizedX = (x - 349.185) / 134.965;
const normalizedY = (y - 322) / 134.965;
Expand All @@ -106,11 +109,11 @@ const distortBoopPoint = (
const oppositeWeight = Math.max(-facing, 0) ** 2;
const sideWeight = Math.max(1 - Math.abs(facing), 0) ** 2;
const directionalOffset = (-30 * cursorWeight * contactDepth - 13 * oppositeWeight) * intensity;
const sideBulge = 11 * sideWeight * intensity;
const radialBulge = (11 * sideWeight + oppositeBulge * oppositeWeight) * intensity;

return [
x + direction.x * directionalOffset + radialX * sideBulge,
y + direction.y * directionalOffset + radialY * sideBulge,
x + direction.x * directionalOffset + radialX * radialBulge,
y + direction.y * directionalOffset + radialY * radialBulge,
];
};

Expand All @@ -120,26 +123,63 @@ const subtractThingPoints = ([leftX, leftY]: ThingPoint, [rightX, rightY]: Thing
[leftX - rightX, leftY - rightY];
const scaleThingPoint = ([x, y]: ThingPoint, scale: number): ThingPoint => [x * scale, y * scale];

const getDiagonalContactAnchors = (pose: Exclude<BrandThingPose, "zz">) => {
switch (pose) {
case "north-east":
return [0, 1] as const;
case "south-east":
return [1, 2] as const;
case "south-west":
return [2, 3] as const;
case "north-west":
return [3, 0] as const;
default:
return null;
}
};

const createBoopCurve = (
start: ThingPoint,
startTangent: ThingPoint,
end: ThingPoint,
endTangent: ThingPoint,
startTensionScale: number,
endTensionScale: number,
) => [
`C${formatThingPoint(addThingPoints(start, scaleThingPoint(startTangent, startTensionScale)))}`,
formatThingPoint(subtractThingPoints(end, scaleThingPoint(endTangent, endTensionScale))),
formatThingPoint(end),
].join(" ");

export const createBoopHeadPath = (
pose: Exclude<BrandThingPose, "zz">,
intensity: number,
) => {
const direction = HOVER_REACTION_DIRECTION_VECTORS[pose === "idle" ? "east" : pose];
const contactDepth = pose.includes("-") ? BOOP_DIAGONAL_CONTACT_DEPTH : 1;
const top = distortBoopPoint(HEAD_PATH_POINTS.top, direction, intensity, contactDepth);
const right = distortBoopPoint(HEAD_PATH_POINTS.right, direction, intensity, contactDepth);
const bottom = distortBoopPoint(HEAD_PATH_POINTS.bottom, direction, intensity, contactDepth);
const left = distortBoopPoint(HEAD_PATH_POINTS.left, direction, intensity, contactDepth);
const isDiagonal = pose.includes("-");
const contactDepth = isDiagonal ? BOOP_DIAGONAL_CONTACT_DEPTH : 1;
const oppositeBulge = isDiagonal ? BOOP_DIAGONAL_OPPOSITE_BULGE : 0;
const top = distortBoopPoint(HEAD_PATH_POINTS.top, direction, intensity, contactDepth, oppositeBulge);
const right = distortBoopPoint(HEAD_PATH_POINTS.right, direction, intensity, contactDepth, oppositeBulge);
const bottom = distortBoopPoint(HEAD_PATH_POINTS.bottom, direction, intensity, contactDepth, oppositeBulge);
const left = distortBoopPoint(HEAD_PATH_POINTS.left, direction, intensity, contactDepth, oppositeBulge);
const topTangent = scaleThingPoint(subtractThingPoints(right, left), BOOP_CURVE_TENSION);
const rightTangent = scaleThingPoint(subtractThingPoints(bottom, top), BOOP_CURVE_TENSION);
const bottomTangent = scaleThingPoint(subtractThingPoints(left, right), BOOP_CURVE_TENSION);
const leftTangent = scaleThingPoint(subtractThingPoints(top, bottom), BOOP_CURVE_TENSION);
const contactAnchors = getDiagonalContactAnchors(pose);
const positiveIntensity = Math.min(Math.max(intensity, 0), 1);
const contactTensionScale = 1 - positiveIntensity * (1 - BOOP_DIAGONAL_CONTACT_TENSION_SCALE);
const anchorTension = (anchor: number) =>
contactAnchors && (contactAnchors[0] === anchor || contactAnchors[1] === anchor)
? contactTensionScale
: 1;

return [
`M${formatThingPoint(top)}`,
`C${formatThingPoint(addThingPoints(top, topTangent))} ${formatThingPoint(subtractThingPoints(right, rightTangent))} ${formatThingPoint(right)}`,
`C${formatThingPoint(addThingPoints(right, rightTangent))} ${formatThingPoint(subtractThingPoints(bottom, bottomTangent))} ${formatThingPoint(bottom)}`,
`C${formatThingPoint(addThingPoints(bottom, bottomTangent))} ${formatThingPoint(subtractThingPoints(left, leftTangent))} ${formatThingPoint(left)}`,
`C${formatThingPoint(addThingPoints(left, leftTangent))} ${formatThingPoint(subtractThingPoints(top, topTangent))} ${formatThingPoint(top)}Z`,
createBoopCurve(top, topTangent, right, rightTangent, anchorTension(0), anchorTension(1)),
createBoopCurve(right, rightTangent, bottom, bottomTangent, anchorTension(1), anchorTension(2)),
createBoopCurve(bottom, bottomTangent, left, leftTangent, anchorTension(2), anchorTension(3)),
`${createBoopCurve(left, leftTangent, top, topTangent, anchorTension(3), anchorTension(0))}Z`,
].join("");
};
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export {
createPrivacyThingLogo,
definePrivacyThingLogo,
dispatchPrivacyThingCommand,
PRIVACY_THING_COMMAND_EVENT,
} from "./privacy-thing-logo.js";
export { createBoopHeadPath } from "./geometry.js";
export {
Expand All @@ -25,8 +27,10 @@ export {
export type {
BrandThingDirectionPose,
BrandThingHoverReaction,
BrandThingLookDirection,
BrandThingPose,
BrandThingTiming,
PrivacyThingCommand,
PrivacyThingLogoElement,
PrivacyThingLogoOptions,
PrivacyThingLogoVariant,
Expand Down
34 changes: 34 additions & 0 deletions src/privacy-thing-logo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
type BrandThingHoverReaction,
type BrandThingPose,
type BrandThingTiming,
type PrivacyThingCommand,
type PrivacyThingLogoElement,
type PrivacyThingLogoOptions,
type PrivacyThingLogoVariant,
} from "./types.js";

const DEFAULT_LABEL = "Privacy Thing";
export const PRIVACY_THING_COMMAND_EVENT = "privacy-thing:command" as const;
let instanceSequence = 0;

const BOOLEAN_ATTRIBUTES = [
Expand All @@ -35,6 +37,8 @@ const isPose = (value: string | null): value is BrandThingPose =>
value !== null && (BRAND_THING_POSES as readonly string[]).includes(value);
const isHoverReaction = (value: string | null): value is BrandThingHoverReaction =>
value !== null && (BRAND_THING_HOVER_REACTIONS as readonly string[]).includes(value);
const isLookDirection = (value: unknown): value is Exclude<BrandThingPose, "zz"> =>
typeof value === "string" && value !== "zz" && isPose(value);

const createElementClass = () => class PrivacyThingLogoElementImpl
extends HTMLElement
Expand All @@ -57,11 +61,13 @@ const createElementClass = () => class PrivacyThingLogoElementImpl

connectedCallback() {
this.#connected = true;
this.addEventListener(PRIVACY_THING_COMMAND_EVENT, this.#onCommand);
this.#render();
}

disconnectedCallback() {
this.#connected = false;
this.removeEventListener(PRIVACY_THING_COMMAND_EVENT, this.#onCommand);
this.#thingController?.disconnect();
this.#thingController = null;
}
Expand Down Expand Up @@ -141,6 +147,22 @@ const createElementClass = () => class PrivacyThingLogoElementImpl
this.#queueRender();
}

#onCommand = (event: Event) => {
const command = (event as CustomEvent<unknown>).detail;
if (!command || typeof command !== "object" || !("type" in command)) return;
if (command.type === "reset") {
this.#thingController?.resetLook();
return;
}
if (
command.type === "look"
&& "direction" in command
&& isLookDirection(command.direction)
) {
this.#thingController?.look(command.direction);
}
};

#queueRender() {
if (!this.#connected || this.#renderQueued) return;
this.#renderQueued = true;
Expand Down Expand Up @@ -221,3 +243,15 @@ export const createPrivacyThingLogo = (
element.configure(options);
return element;
};

export const dispatchPrivacyThingCommand = (
element: PrivacyThingLogoElement,
command: PrivacyThingCommand,
) => {
const EventConstructor = element.ownerDocument.defaultView?.CustomEvent;
if (!EventConstructor) return false;
return element.dispatchEvent(new EventConstructor<PrivacyThingCommand>(
PRIVACY_THING_COMMAND_EVENT,
{ detail: command },
));
};
38 changes: 35 additions & 3 deletions src/thing-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ const THING_VIEWBOX_HEIGHT = 306;
const formatTranslationPercent = (value: number, dimension: number) =>
`${Number(((value / dimension) * 100).toFixed(6))}%`;

const getBoopEyePose = (direction: BrandThingDirectionPose | "idle") => {
switch (direction) {
case "north-east":
return "south-west";
case "south-east":
return "north-west";
case "south-west":
return "north-east";
case "north-west":
return "south-east";
default:
return direction;
}
};

const readAnimatedPath = (path: SVGPathElement) => {
const computedPath = window.getComputedStyle(path).getPropertyValue("d");
const match = computedPath?.match(/^path\((["']?)(.*)\1\)$/);
Expand Down Expand Up @@ -62,6 +77,20 @@ export class ThingController {
this.#render();
}

look(direction: Exclude<BrandThingPose, "zz">) {
if (this.#options.pose === "zz") return;
this.#clearTimers();
this.#cancelAnimations();
this.#active = false;
this.#tracking = false;
this.#setPhase("idle");
this.#applyPose(direction);
}

resetLook() {
if (this.#options.pose !== "zz") this.look(this.#options.pose);
}

disconnect() {
this.#teardownRuntime();
this.#host.replaceChildren();
Expand Down Expand Up @@ -175,7 +204,7 @@ export class ThingController {
this.#root.dataset.hoverReactionSequence = String(this.#sequence);
this.#root.dataset.hoverReactionDirection = direction;
this.#setPhase("notice");
this.#applyPose(direction);
this.#applyPose(getBoopEyePose(direction));
this.#removeLookAnimations();

const head = this.#root.querySelector<SVGPathElement>("[data-thing-head]");
Expand All @@ -193,11 +222,14 @@ export class ThingController {
}
if (svg) {
const vector = HOVER_REACTION_DIRECTION_VECTORS[direction];
const isDiagonal = direction.includes("-");
const recoilTravel = isDiagonal ? 0 : 3.2;
const settleTravel = isDiagonal ? 0 : 1.024;
this.#animateElement(svg, [
{ offset: 0, transform: readAnimatedTransform(svg) },
{ offset: 0.18, transform: `translate(${vector.x * 0.45}%, ${vector.y * 0.45}%) scale(1.015, .985)` },
{ offset: 0.42, transform: `translate(${vector.x * -3.2}%, ${vector.y * -3.2}%) scale(.985, 1.015)` },
{ offset: 0.66, transform: `translate(${vector.x * -1.024}%, ${vector.y * -1.024}%) scale(1.012, .988)` },
{ offset: 0.42, transform: `translate(${vector.x * -recoilTravel}%, ${vector.y * -recoilTravel}%) scale(.985, 1.015)` },
{ offset: 0.66, transform: `translate(${vector.x * -settleTravel}%, ${vector.y * -settleTravel}%) scale(1.012, .988)` },
{ offset: 0.82, transform: "translate(0, 0) scale(.996, 1.004)" },
{ offset: 1, transform: "translate(0, 0) scale(1)" },
], {
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export const BRAND_THING_DIRECTION_POSES = BRAND_THING_POSES.filter(

export type BrandThingDirectionPose = typeof BRAND_THING_DIRECTION_POSES[number];

/** Awake gaze directions accepted by imperative Thing commands. */
export type BrandThingLookDirection = Exclude<BrandThingPose, "zz">;

/** Framework-agnostic commands dispatched directly to a logo element. */
export type PrivacyThingCommand =
| Readonly<{ type: "look"; direction: BrandThingLookDirection }>
| Readonly<{ type: "reset" }>;

export const BRAND_THING_HOVER_REACTIONS = ["none", "boop"] as const;
export type BrandThingHoverReaction = typeof BRAND_THING_HOVER_REACTIONS[number];

Expand Down
Loading
Loading