Setting the file. One moment. Interaction Timeline · Wix Headless Replatform · wix/skills · Skills Docs28.10
Workflow
This file
- Number
- 28.51
- Position
- 51 of 89
- Type
- JavaScript
- Size
- 14 KB
- Lines
- 348
scripts/lib/interaction-timeline.mjs
JavaScript·348 lines·14 KB
,
13 "gridTemplateColumns",
14 "overflow",
15 "clipPath",
16 "gap",
17 "rowGap",
18 "columnGap",
19 "marginLeft",
20 "marginRight",
21 "paddingLeft",
22 "paddingRight",
23 "borderLeftWidth",
24 "borderRightWidth",
25 "borderLeftColor",
26 "borderRightColor",
27];
28
29export const DEFAULT_INTERACTION_TIMELINE_MS = [0, 80, 180, 400, 900, 1400];
30
31export function compactInteractionTimeline(frames, { maxChangedNodes = 36 } = {}) {
32 const normalized = (frames || []).filter(Boolean).map((frame, index) => ({
33 ...frame,
34 atMs: Number.isFinite(frame.atMs) ? frame.atMs : index === 0 ? -1 : 0,
35 nodes: Array.isArray(frame.nodes) ? frame.nodes : [],
36 }));
37 if (normalized.length < 2) {
38 return {
39 sampledAtMs: normalized.map((frame) => frame.atMs),
40 observedSettleMs: 0,
41 changedNodeCount: 0,
42 changes: [],
43 };
44 }
45
46 const paths = new Set(normalized.flatMap((frame) => frame.nodes.map((node) => node.path).filter(Boolean)));
47 const changes = [];
48 for (const path of paths) {
49 const states = normalized.map((frame) => {
50 const node = frame.nodes.find((candidate) => candidate.path === path);
51 return node ? { atMs: frame.atMs, ...pickState(node) } : { atMs: frame.atMs, missing: true };
52 });
53 const changedProperties = changedKeys(states);
54 if (!changedProperties.length) continue;
55 changes.push({
56 path,
57 role: states.find((state) => state.role)?.role || "descendant",
58 changedProperties,
59 states: compactStates(states, changedProperties),
60 });
61 }
62
63 changes.sort((left, right) => changePriority(right) - changePriority(left));
64 const retained = changes.slice(0, maxChangedNodes);
65 return {
66 sampledAtMs: normalized.map((frame) => frame.atMs),
67 observedSettleMs: observedSettleMs(retained),
68 changedNodeCount: changes.length,
69 truncated: changes.length > retained.length,
70 changes: retained,
71 animations: compactAnimations(normalized),
72 };
73}
74
75export function deriveCarouselInvariants({ frames, timeline } = {}) {
76 const normalized = (frames || []).filter(Boolean);
77 const before = normalized[0] || {};
78 const after = normalized[normalized.length - 1] || {};
79 const beforeItems = itemNodes(before);
80 const afterItems = itemNodes(after);
81 const beforeWidths = beforeItems.map(widthOf).filter((value) => value > 0);
82 const afterWidths = afterItems.map(widthOf).filter((value) => value > 0);
83 const collapsedWidth = median(beforeWidths);
84 const expandedWidth = afterWidths.length ? Math.max(...afterWidths) : 0;
85 const initialActiveIndexes = beforeItems.map((item, index) => isActive(item) ? index : -1).filter((index) => index >= 0);
86 const activeIndexes = afterItems.map((item, index) => isActive(item) ? index : -1).filter((index) => index >= 0);
87 const activeCount = afterItems.filter(isActive).length;
88 const beforeTrack = before.nodes?.find((node) => node.role === "track");
89 const afterTrack = after.nodes?.find((node) => node.role === "track") || beforeTrack;
90 const clientWidth = numberOr(afterTrack?.clientWidth, 0);
91 const scrollWidth = numberOr(afterTrack?.scrollWidth, 0);
92 const geometricGaps = consecutiveGaps(beforeItems);
93 const measuredGap = median(geometricGaps);
94 const trackGap = px(beforeTrack?.columnGap || beforeTrack?.gap);
95 const borderWidths = beforeItems.flatMap((item) => [px(item.borderLeftWidth), px(item.borderRightWidth)]).filter((value) => value > 0);
96 const marginWidths = beforeItems.flatMap((item) => [px(item.marginLeft), px(item.marginRight)]).filter((value) => value > 0);
97 const separationMechanism = trackGap > 0 ? "track-gap"
98 : measuredGap > 0.5 ? "geometric-gap"
99 : marginWidths.length ? "item-margin"
100 : borderWidths.length ? "divider"
101 : "flush";
102 return {
103 itemCount: Math.max(beforeItems.length, afterItems.length),
104 activeCount,
105 activeIndexes,
106 singleActive: activeCount === 1,
107 initialState: initialActiveIndexes.length === 0 ? "all-collapsed" : initialActiveIndexes.length === 1 ? "single-active" : "multiple-active",
108 initialActiveCount: initialActiveIndexes.length,
109 initialActiveIndexes,
110 collapsedItemWidth: round(collapsedWidth),
111 expandedItemWidth: round(expandedWidth),
112 expandedWidthRatio: collapsedWidth > 0 ? round(expandedWidth / collapsedWidth, 3) : 0,
113 horizontalOverflowRatio: clientWidth > 0 ? round(scrollWidth / clientWidth, 3) : 0,
114 separation: {
115 mechanism: separationMechanism,
116 trackGap: round(trackGap, 2),
117 geometricGap: round(measuredGap, 2),
118 minimumGeometricGap: geometricGaps.length ? round(Math.min(...geometricGaps), 2) : 0,
119 maximumGeometricGap: geometricGaps.length ? round(Math.max(...geometricGaps), 2) : 0,
120 dividerWidth: round(median(borderWidths), 2),
121 itemMargin: round(median(marginWidths), 2),
122 contentInset: round(median(beforeItems.flatMap((item) => [px(item.paddingLeft), px(item.paddingRight)]).filter((value) => value > 0)), 2),
123 },
124 observedSettleMs: timeline?.observedSettleMs || 0,
125 };
126}
127
128export function deriveScrollInvariants(phases = []) {
129 const samples = (phases || []).filter(Boolean);
130 const chainCount = Math.max(0, ...samples.map((phase) => phase.visualChain?.length || 0));
131 let strongest = null;
132 for (let index = 0; index < chainCount; index += 1) {
133 const nodes = samples.map((phase) => phase.visualChain?.[index]).filter(Boolean);
134 const widths = nodes.map((node) => numberOr(node.width, 0)).filter((value) => value > 0);
135 const scales = nodes.map((node) => transformScale(node.transform)).filter((value) => value > 0);
136 const widthRatio = rangeRatio(widths);
137 const scaleRatio = rangeRatio(scales);
138 const score = Math.max(widthRatio, scaleRatio);
139 if (!strongest || score > strongest.score) {
140 const representative = nodes.find((node) => node.className) || nodes[0] || {};
141 strongest = {
142 score,
143 chainIndex: index,
144 tag: representative.tag || "",
145 classTokens: String(representative.className || "").split(/\s+/).filter(Boolean).slice(0, 6),
146 minimumWidth: widths.length ? Math.min(...widths) : 0,
147 maximumWidth: widths.length ? Math.max(...widths) : 0,
148 widthRatio: round(widthRatio, 3),
149 transformScaleRatio: round(scaleRatio, 3),
150 };
151 }
152 }
153 const curtainTotals = samples.map((phase) => (phase.sceneLayers || [])
154 .filter((layer) => layer.role === "curtain")
155 .reduce((total, layer) => total + Math.max(0, numberOr(layer.width, 0)), 0));
156 const rootWidths = samples.map((phase) => numberOr(phase.rootWidth, 0)).filter((value) => value > 0);
157 const viewportWidth = median(rootWidths);
158 const curtainRevealFraction = curtainTotals.length && viewportWidth > 0
159 ? (Math.max(...curtainTotals) - Math.min(...curtainTotals)) / viewportWidth
160 : 0;
161 const curtainRevealRatio = 1 + Math.max(0, curtainRevealFraction);
162 const contentWidths = samples.map((phase) => numberOr(phase.copy?.width, 0)).filter((value) => value > 0);
163 const contentHeights = samples.map((phase) => numberOr(phase.copy?.height, 0)).filter((value) => value > 0);
164 const contentScales = samples.map((phase) => transformScale(phase.copy?.transform)).filter((value) => value > 0);
165 const contentWidthRatio = rangeRatio(contentWidths);
166 const contentHeightRatio = rangeRatio(contentHeights);
167 const contentScaleRatio = rangeRatio(contentScales);
168 if (strongest && curtainRevealRatio > strongest.score) {
169 strongest = {
170 ...strongest,
171 score: round(curtainRevealRatio, 3),
172 mechanism: "curtain-reveal",
173 };
174 } else if (strongest) {
175 strongest.mechanism = strongest.widthRatio >= strongest.transformScaleRatio ? "geometry" : "transform";
176 }
177 return {
178 entryPhaseCount: samples.filter((phase) => Number(phase.progress) < 0).length,
179 pinnedPhaseCount: samples.filter((phase) => (phase.visualChain || []).some((node) => node.position === "fixed" || node.position === "sticky")).length,
180 visualExpansion: strongest || {
181 score: 1,
182 chainIndex: 0,
183 tag: "",
184 classTokens: [],
185 minimumWidth: 0,
186 maximumWidth: 0,
187 widthRatio: 1,
188 transformScaleRatio: 1,
189 mechanism: "none",
190 },
191 curtainReveal: {
192 observed: curtainRevealFraction > 0.05,
193 maximumCombinedWidth: curtainTotals.length ? Math.max(...curtainTotals) : 0,
194 minimumCombinedWidth: curtainTotals.length ? Math.min(...curtainTotals) : 0,
195 viewportFraction: round(Math.max(0, curtainRevealFraction), 3),
196 },
197 motionOwnership: {
198 visual: {
199 expands: Number(strongest?.score || 1) > 1.05,
200 mechanism: strongest?.mechanism || "none",
201 },
202 content: {
203 widthRatio: round(contentWidthRatio, 3),
204 heightRatio: round(contentHeightRatio, 3),
205 transformScaleRatio: round(contentScaleRatio, 3),
206 scales: Math.max(contentWidthRatio, contentHeightRatio, contentScaleRatio) > 1.05,
207 maximumAllowedScaleRatio: Math.max(contentWidthRatio, contentHeightRatio, contentScaleRatio) > 1.05 ? null : 1.03,
208 },
209 },
210 };
211}
212
213function consecutiveGaps(items) {
214 return items.slice(1).map((item, index) => {
215 const previous = items[index];
216 return numberOr(item?.rect?.left, 0) - (numberOr(previous?.rect?.left, 0) + numberOr(previous?.rect?.width, 0));
217 }).filter((value) => Number.isFinite(value) && value >= -0.5);
218}
219
220function px(value) {
221 const numeric = Number.parseFloat(String(value || "0"));
222 return Number.isFinite(numeric) ? numeric : 0;
223}
224
225function pickState(node) {
226 const state = {
227 role: node.role || "descendant",
228 text: String(node.text || "").slice(0, 160),
229 rect: node.rect || null,
230 };
231 for (const key of DEFAULT_STYLE_KEYS) {
232 if (node[key] !== undefined) state[key] = node[key];
233 }
234 if (node.clientWidth !== undefined) state.clientWidth = node.clientWidth;
235 if (node.scrollWidth !== undefined) state.scrollWidth = node.scrollWidth;
236 return state;
237}
238
239function changedKeys(states) {
240 const keys = new Set(states.flatMap((state) => Object.keys(state)).filter((key) => !["atMs", "role"].includes(key)));
241 return Array.from(keys).filter((key) => {
242 const values = states.map((state) => stable(state[key]));
243 return values.some((value) => value !== values[0]);
244 });
245}
246
247function compactStates(states, changedProperties) {
248 let previous = null;
249 const compacted = [];
250 for (const state of states) {
251 const next = { atMs: state.atMs };
252 for (const key of changedProperties) next[key] = state[key];
253 const signature = stable(next, ["atMs"]);
254 if (signature === previous) continue;
255 compacted.push(next);
256 previous = signature;
257 }
258 return compacted;
259}
260
261function observedSettleMs(changes) {
262 let settled = 0;
263 for (const change of changes) {
264 const states = change.states || [];
265 if (states.length < 2) continue;
266 settled = Math.max(settled, numberOr(states[states.length - 1].atMs, 0));
267 }
268 return settled;
269}
270
271function compactAnimations(frames) {
272 const seen = new Map();
273 for (const frame of frames) {
274 for (const animation of frame.animations || []) {
275 const key = `${animation.path || "unknown"}\u0000${animation.name || ""}\u0000${animation.duration || 0}\u0000${animation.easing || ""}`;
276 const existing = seen.get(key) || { ...animation, firstSeenAtMs: frame.atMs, lastSeenAtMs: frame.atMs };
277 existing.lastSeenAtMs = frame.atMs;
278 seen.set(key, existing);
279 }
280 }
281 return Array.from(seen.values()).slice(0, 24);
282}
283
284function itemNodes(frame) {
285 return (frame?.nodes || []).filter((node) => node.role === "item");
286}
287
288function widthOf(node) {
289 return numberOr(node?.rect?.width, numberOr(node?.width?.replace?.("px", ""), 0));
290}
291
292function isActive(node) {
293 return node?.ariaSelected === "true" || node?.ariaExpanded === "true" || node?.active === true || hasActiveToken(node?.className);
294}
295
296function hasActiveToken(value) {
297 return String(value || "").split(/\s+/).some((token) => /(?:^|[-_:])(active|selected|expanded)$|^(active|selected|expanded)$/i.test(token));
298}
299
300function median(values) {
301 if (!values.length) return 0;
302 const sorted = [...values].sort((left, right) => left - right);
303 const middle = Math.floor(sorted.length / 2);
304 return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
305}
306
307function changePriority(change) {
308 const properties = new Set(change.changedProperties || []);
309 let score = change.role === "item" ? 30 : change.role === "track" ? 25 : 0;
310 if (properties.has("className") || properties.has("ariaSelected") || properties.has("ariaExpanded")) score += 20;
311 if (properties.has("rect") || properties.has("transform") || properties.has("opacity")) score += 15;
312 if (properties.has("text") || properties.has("display") || properties.has("visibility")) score += 10;
313 return score;
314}
315
316function stable(value, ignoredKeys = []) {
317 if (value === undefined) return "__undefined__";
318 if (value === null || typeof value !== "object") return JSON.stringify(value);
319 if (Array.isArray(value)) return `[${value.map((item) => stable(item, ignoredKeys)).join(",")}]`;
320 return `{${Object.keys(value).filter((key) => !ignoredKeys.includes(key)).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key], ignoredKeys)}`).join(",")}}`;
321}
322
323function numberOr(value, fallback) {
324 const parsed = Number(value);
325 return Number.isFinite(parsed) ? parsed : fallback;
326}
327
328function round(value, precision = 1) {
329 const factor = 10 ** precision;
330 return Math.round(value * factor) / factor;
331}
332
333function rangeRatio(values) {
334 if (!values.length) return 1;
335 const minimum = Math.min(...values);
336 return minimum > 0 ? Math.max(...values) / minimum : 1;
337}
338
339function transformScale(value) {
340 const text = String(value || "");
341 const matrix = text.match(/^matrix\(([^)]+)\)$/);
342 if (matrix) {
343 const [a, b] = matrix[1].split(",").map(Number);
344 return Math.sqrt((a || 0) ** 2 + (b || 0) ** 2) || 1;
345 }
346 const scale = text.match(/scale(?:X)?\(([-+\d.]+)\)/);
347 return scale ? Number(scale[1]) || 1 : 1;
348}