Files
hyperframes/registry/components/cursor-annotate/cursor-annotate.html
T

405 lines
16 KiB
HTML
Vendored

<!doctype html>
<!--
Annotate Cursor (actor primitive), mountable sub-composition
concept: a marker pen glides to a region of the host UI, touches down and hand-draws
one annotation around it (a loose loop, an underline, or an arrow), then lifts and
leaves. The ink is revealed exactly under the nib: both read the same path length.
mount contract: loaded by a host through data-composition-src; only <template> is
cloned. #root fills the host clip (inset:0). See demo.html.
variables:
shape (circle|underline|arrow) the mark the pen draws
color marker ink
target_x, target_y, target_w, target_h (% of host box) the region being marked
envelope (default 4.2s): glide in 0-0.8s, touch down, draw 0.9-2.0s, lift,
elastic hold, exit in the last 0.55s. Shorter durations compress every beat evenly.
-->
<html
lang="en"
data-composition-id="cursor-annotate"
data-composition-duration="4.2"
data-composition-variables='[
{ "id": "shape", "type": "enum", "role": "style", "label": "Mark", "default": "circle", "options": [ { "value": "circle", "label": "Loose loop" }, { "value": "underline", "label": "Underline" }, { "value": "arrow", "label": "Arrow" } ] },
{ "id": "color", "type": "color", "role": "style", "label": "Ink", "default": "#f43f5e" },
{ "id": "target_x", "type": "number", "role": "layout", "label": "Region left", "default": 40, "min": 0, "max": 95, "step": 1, "unit": "%" },
{ "id": "target_y", "type": "number", "role": "layout", "label": "Region top", "default": 38, "min": 0, "max": 95, "step": 1, "unit": "%" },
{ "id": "target_w", "type": "number", "role": "layout", "label": "Region width", "default": 20, "min": 2, "max": 90, "step": 1, "unit": "%" },
{ "id": "target_h", "type": "number", "role": "layout", "label": "Region height", "default": 16, "min": 2, "max": 90, "step": 1, "unit": "%" }
]'
>
<head>
<meta charset="UTF-8" />
<title>Annotate Cursor</title>
</head>
<body>
<template>
<style>
#root {
position: absolute;
inset: 0;
overflow: hidden;
isolation: isolate;
pointer-events: none;
}
.ca-ink {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.ca-ink path {
fill: none;
stroke: var(--ca-ink, #f43f5e);
stroke-linecap: round;
stroke-linejoin: round;
}
.ca-pen {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
will-change: transform;
}
/* The nib sits at the svg origin, so the wrapper's translate places the nib. */
.ca-pen svg {
position: absolute;
left: -40px;
top: -200px;
width: 240px;
height: 240px;
overflow: visible;
}
</style>
<div id="root" data-composition-id="cursor-annotate" data-duration="4.2">
<svg class="ca-ink" id="ca-ink" aria-hidden="true"></svg>
<div class="ca-pen" id="ca-pen" aria-hidden="true">
<svg viewBox="-40 -200 240 240" aria-hidden="true">
<g id="ca-pen-body">
<g transform="rotate(-50)">
<path d="M0 0 L15 -5.5 L15 5.5 Z" fill="#1d1d22" />
<path id="ca-pen-tip" d="M3 -1.4 L15 -5.5 L15 5.5 L3 1.4 Z" />
<rect x="15" y="-7.5" width="14" height="15" rx="2" fill="#2a2a31" />
<rect
x="29"
y="-9"
width="62"
height="18"
rx="4"
fill="#f6f6f8"
stroke="#1d1d22"
stroke-width="1.5"
/>
<rect x="36" y="-9" width="8" height="18" id="ca-pen-band" />
<rect x="91" y="-9.5" width="16" height="19" rx="6" fill="#1d1d22" />
</g>
</g>
</svg>
</div>
</div>
<script>
(function () {
"use strict";
var html = document.documentElement;
var root = document.getElementById("root");
var ink = document.getElementById("ca-ink");
var pen = document.getElementById("ca-pen");
var penBody = document.getElementById("ca-pen-body");
var SVG_NS = "http://www.w3.org/2000/svg";
var DEFAULTS = {};
try {
JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(
function (variable) {
DEFAULTS[variable.id] = variable.default;
},
);
} catch (error) {
/* malformed declaration falls back to the inline defaults below */
}
var scopedVariables = {};
try {
if (window.__hyperframes && typeof window.__hyperframes.getVariables === "function") {
scopedVariables = window.__hyperframes.getVariables() || {};
}
} catch (error) {
/* no scoped variables API in this context */
}
var vars = Object.assign({}, DEFAULTS, window.__hfVariables || {}, scopedVariables);
function num(value, fallback, lo, hi) {
var n = Number(value);
if (!isFinite(n)) n = fallback;
return Math.max(lo, Math.min(hi, n));
}
// Hardcoded: the compositor rewrites the wrapper's attributes on mount.
var compositionId = "cursor-annotate";
var duration = Math.max(
0.001,
parseFloat(
root.dataset.duration || html.getAttribute("data-composition-duration") || "4.2",
),
);
var shape =
["circle", "underline", "arrow"].indexOf(vars.shape) >= 0 ? vars.shape : "circle";
var color = /^#[0-9a-f]{3,8}$/i.test(String(vars.color)) ? vars.color : "#f43f5e";
var W = root.clientWidth || 1920;
var H = root.clientHeight || 1080;
var unit = W / 1920;
var box = {
x: (num(vars.target_x, 40, 0, 100) / 100) * W,
y: (num(vars.target_y, 38, 0, 100) / 100) * H,
w: (num(vars.target_w, 20, 1, 100) / 100) * W,
h: (num(vars.target_h, 16, 1, 100) / 100) * H,
};
ink.setAttribute("viewBox", "0 0 " + W + " " + H);
root.style.setProperty("--ca-ink", color);
document.getElementById("ca-pen-tip").setAttribute("fill", color);
document.getElementById("ca-pen-band").setAttribute("fill", color);
function polyline(points) {
return points
.map(function (p, i) {
return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1);
})
.join(" ");
}
// A hand loop: starts upper-left, runs a little past its start and wider on the way out.
function loopStroke() {
var cx = box.x + box.w / 2;
var cy = box.y + box.h / 2;
var rx = box.w * 0.62 + 18 * unit;
var ry = box.h * 0.66 + 16 * unit;
var tilt = (-5 * Math.PI) / 180;
var start = -2.55;
var sweep = Math.PI * 2 + 0.5;
var pts = [];
for (var i = 0; i <= 140; i++) {
var u = i / 140;
var a = start + sweep * u;
var r = (1 + 0.035 * Math.sin(3 * a + 1.1)) * (1 + 0.08 * u);
var ex = Math.cos(a) * rx * r;
var ey = Math.sin(a) * ry * r;
pts.push([
cx + ex * Math.cos(tilt) - ey * Math.sin(tilt),
cy + ex * Math.sin(tilt) + ey * Math.cos(tilt),
]);
}
return [polyline(pts)];
}
function underlineStroke() {
var y = box.y + box.h + 14 * unit;
var x0 = box.x - box.w * 0.04;
var span = box.w * 1.08;
var pts = [];
for (var i = 0; i <= 48; i++) {
var u = i / 48;
pts.push([
x0 + span * u,
y + 4 * unit * Math.sin(u * Math.PI * 1.3) - 12 * unit * u * u,
]);
}
return [polyline(pts)];
}
// Shaft from below-left, curving onto the region; the head is a second stroke.
function arrowStrokes() {
var tip = { x: box.x - 10 * unit, y: box.y + box.h + 8 * unit };
var from = {
x: tip.x - Math.max(160 * unit, box.w * 0.7),
y: tip.y + Math.max(120 * unit, box.h * 0.9),
};
var ctrl = { x: from.x + (tip.x - from.x) * 0.15, y: tip.y + (from.y - tip.y) * 0.05 };
var pts = [];
for (var i = 0; i <= 40; i++) {
var u = i / 40;
var v = 1 - u;
pts.push([
v * v * from.x + 2 * v * u * ctrl.x + u * u * tip.x,
v * v * from.y + 2 * v * u * ctrl.y + u * u * tip.y,
]);
}
var back = Math.atan2(ctrl.y - tip.y, ctrl.x - tip.x);
var len = 30 * unit;
var barb = function (spread) {
return [tip.x + Math.cos(back + spread) * len, tip.y + Math.sin(back + spread) * len];
};
return [polyline(pts), polyline([barb(0.5), [tip.x, tip.y], barb(-0.5)])];
}
var strokeDs =
shape === "underline"
? underlineStroke()
: shape === "arrow"
? arrowStrokes()
: loopStroke();
var WIDTH = 11 * unit;
var strokes = strokeDs.map(function (d) {
var thin = document.createElementNS(SVG_NS, "path");
var thick = document.createElementNS(SVG_NS, "path");
thin.setAttribute("d", d);
thick.setAttribute("d", d);
thin.setAttribute("stroke-width", (WIDTH * 0.62).toFixed(2));
thick.setAttribute("stroke-width", WIDTH.toFixed(2));
ink.appendChild(thin);
ink.appendChild(thick);
return { thin: thin, thick: thick, length: thin.getTotalLength() };
});
// Beats at the default 4.2s; a shorter composition compresses them evenly.
var squeeze = Math.min(1, duration / 3.3);
var T_LAND = 0.8 * squeeze;
var T_DRAW = 0.9 * squeeze;
var HOP = 0.2 * squeeze;
var drawTotal = (shape === "circle" ? 1.1 : shape === "arrow" ? 0.95 : 0.6) * squeeze;
var totalLength = strokes.reduce(function (sum, s) {
return sum + s.length;
}, 0);
var windows = [];
var cursorT = T_DRAW;
strokes.forEach(function (s, i) {
var span = (drawTotal - HOP * (strokes.length - 1)) * (s.length / totalLength);
windows.push({ start: cursorT, end: cursorT + span });
cursorT += span + (i < strokes.length - 1 ? HOP : 0);
});
var T_LIFT = cursorT;
var T_EXIT = Math.max(T_LIFT + 0.4 * squeeze, duration - 0.55 * squeeze);
function clamp01(u) {
return u < 0 ? 0 : u > 1 ? 1 : u;
}
var glide = gsap.parseEase("power3.out");
var stroke = gsap.parseEase("sine.inOut");
var hop = gsap.parseEase("power2.inOut");
var leave = gsap.parseEase("power2.in");
function pointOn(s, u) {
var p = s.thin.getPointAtLength(clamp01(u) * s.length);
return { x: p.x, y: p.y };
}
var first = pointOn(strokes[0], 0);
var lastStroke = strokes[strokes.length - 1];
var endPoint = pointOn(lastStroke, 1);
var START = { x: first.x + 0.3 * W, y: H * 1.25 };
var ASIDE = { x: endPoint.x + 70 * unit, y: endPoint.y + 50 * unit };
var END = { x: W * 1.15, y: H * 1.2 };
function mix(a, b, u) {
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u };
}
// Returns the nib position and how far off the paper it is (0 = touching).
function penAt(t) {
if (t <= T_LAND) return { p: mix(START, first, glide(clamp01(t / T_LAND))), lift: 1 };
if (t <= T_DRAW)
return { p: first, lift: 1 - clamp01((t - T_LAND) / (T_DRAW - T_LAND)) };
for (var i = 0; i < windows.length; i++) {
var w = windows[i];
if (t <= w.end)
return {
p: pointOn(strokes[i], stroke((t - w.start) / (w.end - w.start))),
lift: 0,
};
var next = windows[i + 1];
if (next && t < next.start) {
var u = (t - w.end) / (next.start - w.end);
return {
p: mix(pointOn(strokes[i], 1), pointOn(strokes[i + 1], 0), hop(u)),
lift: Math.sin(Math.PI * u),
};
}
}
if (t <= T_EXIT) {
var a = clamp01((t - T_LIFT) / (0.45 * squeeze));
return {
p: mix(endPoint, ASIDE, gsap.parseEase("power2.out")(a)),
lift: Math.min(1, a * 2.5),
};
}
return {
p: mix(ASIDE, END, leave(clamp01((t - T_EXIT) / (duration - T_EXIT)))),
lift: 1,
};
}
function drawn(i, t) {
var w = windows[i];
return stroke(clamp01((t - w.start) / (w.end - w.start))) * strokes[i].length;
}
function render(t) {
strokes.forEach(function (s, i) {
var len = drawn(i, t);
s.thin.style.opacity = len > 0.5 ? "1" : "0";
s.thin.setAttribute(
"stroke-dasharray",
len.toFixed(2) + " " + (s.length * 2).toFixed(2),
);
// The thick core trails the tip and never reaches either end: the stroke tapers.
var core = Math.min(len, s.length * 0.9) - s.length * 0.08;
s.thick.style.opacity = core > 0.5 ? "1" : "0";
s.thick.setAttribute(
"stroke-dasharray",
Math.max(0, core).toFixed(2) + " " + (s.length * 3).toFixed(2),
);
s.thick.setAttribute("stroke-dashoffset", (-s.length * 0.08).toFixed(2));
});
var state = penAt(t);
var lift = state.lift;
pen.style.transform =
"translate(" + state.p.x.toFixed(2) + "px," + state.p.y.toFixed(2) + "px)";
penBody.setAttribute(
"transform",
"scale(" +
((1.45 + 0.14 * lift) * unit).toFixed(4) +
") rotate(" +
(-4 * lift).toFixed(2) +
")",
);
pen.style.filter =
"drop-shadow(" +
(4 + 16 * lift).toFixed(1) +
"px " +
(6 + 20 * lift).toFixed(1) +
"px " +
(3 + 14 * lift).toFixed(1) +
"px rgba(0,0,0," +
(0.32 - 0.14 * lift).toFixed(3) +
"))";
}
var clock = { t: 0 };
var tl = gsap.timeline({ paused: true });
tl.to(
clock,
{
t: duration,
duration: duration,
ease: "none",
onUpdate: function () {
render(clock.t);
},
},
0,
);
render(0);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</template>
</body>
</html>