@swiftbrowser/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +600 -0
  2. package/app/index.html +125 -0
  3. package/index.html +56 -0
  4. package/node/dist/node/apps.d.ts +58 -0
  5. package/node/dist/node/apps.d.ts.map +1 -0
  6. package/node/dist/node/apps.js +60 -0
  7. package/node/dist/node/apps.js.map +1 -0
  8. package/node/dist/node/config.d.ts +42 -0
  9. package/node/dist/node/config.d.ts.map +1 -0
  10. package/node/dist/node/config.js +99 -0
  11. package/node/dist/node/config.js.map +1 -0
  12. package/node/dist/node/examples.d.ts +39 -0
  13. package/node/dist/node/examples.d.ts.map +1 -0
  14. package/node/dist/node/examples.js +116 -0
  15. package/node/dist/node/examples.js.map +1 -0
  16. package/node/dist/node/index.d.ts +47 -0
  17. package/node/dist/node/index.d.ts.map +1 -0
  18. package/node/dist/node/index.js +52 -0
  19. package/node/dist/node/index.js.map +1 -0
  20. package/node/dist/plugins/app-manifests.d.ts +61 -0
  21. package/node/dist/plugins/app-manifests.d.ts.map +1 -0
  22. package/node/dist/plugins/app-manifests.js +168 -0
  23. package/node/dist/plugins/app-manifests.js.map +1 -0
  24. package/node/dist/plugins/asset-catalogs.d.ts +119 -0
  25. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
  26. package/node/dist/plugins/asset-catalogs.js +488 -0
  27. package/node/dist/plugins/asset-catalogs.js.map +1 -0
  28. package/node/dist/plugins/bundle-resources.d.ts +49 -0
  29. package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
  30. package/node/dist/plugins/bundle-resources.js +172 -0
  31. package/node/dist/plugins/bundle-resources.js.map +1 -0
  32. package/node/dist/plugins/swift-wasm.d.ts +33 -0
  33. package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
  34. package/node/dist/plugins/swift-wasm.js +241 -0
  35. package/node/dist/plugins/swift-wasm.js.map +1 -0
  36. package/node/dist/src/assets.d.ts +60 -0
  37. package/node/dist/src/assets.d.ts.map +1 -0
  38. package/node/dist/src/assets.js +54 -0
  39. package/node/dist/src/assets.js.map +1 -0
  40. package/node/dist/src/devEvents.d.ts +31 -0
  41. package/node/dist/src/devEvents.d.ts.map +1 -0
  42. package/node/dist/src/devEvents.js +9 -0
  43. package/node/dist/src/devEvents.js.map +1 -0
  44. package/package.json +56 -0
  45. package/plugins/app-manifests.ts +192 -0
  46. package/plugins/asset-catalogs.ts +558 -0
  47. package/plugins/bundle-resources.ts +195 -0
  48. package/plugins/swift-wasm.ts +259 -0
  49. package/public/_headers +23 -0
  50. package/public/apple-touch-icon.png +0 -0
  51. package/public/icon.svg +14 -0
  52. package/public/manifest.webmanifest +14 -0
  53. package/src/animation.ts +521 -0
  54. package/src/assets.ts +86 -0
  55. package/src/chartDom.ts +481 -0
  56. package/src/devEvents.ts +35 -0
  57. package/src/deviceMode.ts +80 -0
  58. package/src/ghost.ts +25 -0
  59. package/src/interaction.ts +1113 -0
  60. package/src/landing.css +261 -0
  61. package/src/landing.ts +141 -0
  62. package/src/layout/chart.ts +477 -0
  63. package/src/layout/controls.ts +116 -0
  64. package/src/layout/engine.ts +3581 -0
  65. package/src/layout/index.ts +91 -0
  66. package/src/layout/measure.ts +86 -0
  67. package/src/layout/text.ts +124 -0
  68. package/src/layout/types.ts +153 -0
  69. package/src/layout/typography.ts +188 -0
  70. package/src/layoutDom.ts +549 -0
  71. package/src/layoutEvents.ts +110 -0
  72. package/src/locale.ts +64 -0
  73. package/src/main.ts +554 -0
  74. package/src/mock.ts +752 -0
  75. package/src/nodeQueries.ts +60 -0
  76. package/src/protocol.ts +1421 -0
  77. package/src/renderer.ts +2882 -0
  78. package/src/styles.css +2834 -0
  79. package/src/styles.ts +373 -0
  80. package/src/swipe.ts +110 -0
  81. package/src/symbols.ts +595 -0
  82. package/src/typography.ts +76 -0
  83. package/src/vite-env.d.ts +26 -0
  84. package/src/wasm.ts +400 -0
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Phase 7 chart geometry (docs/ops-protocol.md, "Phase 7: charts").
3
+ *
4
+ * Pure functions, DOM-free: the engine feeds measured label sizes in and gets
5
+ * the plot rectangle, axis bands, legend rows and label rects back (all
6
+ * relative to the chart's origin); the DOM side maps unit coordinates onto
7
+ * the plot and builds SVG path data for lines, areas and symbols.
8
+ */
9
+
10
+ import type { Rect, Size } from './types';
11
+
12
+ // Constants from the spec.
13
+ export const CHART_DEFAULT_WIDTH = 300;
14
+ export const CHART_DEFAULT_HEIGHT = 200;
15
+ /** Gap between an axis label and the plot edge (the ticks sit inside it). */
16
+ export const CHART_AXIS_PAD = 6;
17
+ export const CHART_TICK_LENGTH = 4;
18
+ /** Legend glyph: a `symbolSize`-px symbol, or an 18 × 3 line when the item has no symbol. */
19
+ export const CHART_LEGEND_GLYPH = 10;
20
+ export const CHART_LEGEND_LINE: Size = { width: 18, height: 3 };
21
+ /** Glyph → label gap inside a legend item. */
22
+ export const CHART_LEGEND_GAP = 4;
23
+ /** Horizontal distance between legend items (and vertical between wrapped rows). */
24
+ export const CHART_LEGEND_ITEM_SPACING = 12;
25
+ export const CHART_LEGEND_ROW_SPACING = 4;
26
+ /** Gap between the legend and the axes / plot. */
27
+ export const CHART_LEGEND_MARGIN = 8;
28
+
29
+ export type ChartAxisPosition = 'bottom' | 'top' | 'leading' | 'trailing';
30
+ export type ChartLegendPosition = 'top' | 'bottom' | 'leading' | 'trailing';
31
+ export type ChartItemPosition = 'top' | 'bottom' | 'leading' | 'trailing' | 'overlay';
32
+
33
+ export interface ChartAxisInput {
34
+ position: ChartAxisPosition;
35
+ /** Text labels: unit position and measured width (height is `labelHeight`). */
36
+ labels: { at: number; width: number }[];
37
+ /** Line height of the axis font. */
38
+ labelHeight: number;
39
+ /** View labels (`xLabel` / `yLabel` children) at their ideal size. */
40
+ views: { at: number; size: Size }[];
41
+ }
42
+
43
+ export interface ChartLegendInput {
44
+ position: ChartLegendPosition;
45
+ /** Measured width of each item (glyph + gap + label). */
46
+ itemWidths: number[];
47
+ itemHeight: number;
48
+ }
49
+
50
+ export interface ChartLayoutInput {
51
+ width: number;
52
+ height: number;
53
+ xAxis: ChartAxisInput | null;
54
+ yAxis: ChartAxisInput | null;
55
+ legend: ChartLegendInput | null;
56
+ }
57
+
58
+ /** Everything relative to the chart's origin. */
59
+ export interface ChartGeometry {
60
+ plot: Rect;
61
+ legend: Rect | null;
62
+ xBand: Rect | null;
63
+ yBand: Rect | null;
64
+ xLabels: Rect[];
65
+ yLabels: Rect[];
66
+ xViews: Rect[];
67
+ yViews: Rect[];
68
+ legendItems: Rect[];
69
+ }
70
+
71
+ /**
72
+ * The chart's size for a proposal: the proposal on both axes, 300 × 200 for
73
+ * nil axes. An infinite axis stays infinite (the chart is as flexible as a
74
+ * shape); callers that need pixels pass `null` for it.
75
+ */
76
+ export function chartSize(width: number | null, height: number | null): Size {
77
+ return {
78
+ width: width !== null && !Number.isNaN(width) ? Math.max(0, width) : CHART_DEFAULT_WIDTH,
79
+ height: height !== null && !Number.isNaN(height) ? Math.max(0, height) : CHART_DEFAULT_HEIGHT,
80
+ };
81
+ }
82
+
83
+ /** Thickness of an axis band: the largest label on its cross axis plus the pad, 0 without labels. */
84
+ export function axisBandThickness(axis: ChartAxisInput | null, cross: 'width' | 'height'): number {
85
+ if (!axis) return 0;
86
+ let largest = 0;
87
+ let any = false;
88
+ for (const label of axis.labels) {
89
+ any = true;
90
+ largest = Math.max(largest, cross === 'width' ? label.width : axis.labelHeight);
91
+ }
92
+ for (const view of axis.views) {
93
+ any = true;
94
+ largest = Math.max(largest, view.size[cross]);
95
+ }
96
+ return any ? largest + CHART_AXIS_PAD : 0;
97
+ }
98
+
99
+ export interface LegendRows {
100
+ size: Size;
101
+ /** One rect per item, relative to the legend's origin. */
102
+ items: Rect[];
103
+ }
104
+
105
+ /**
106
+ * Flow legend items horizontally with 12 px between them, wrapping at
107
+ * `maxWidth` (one item per row when `column`), rows 4 px apart.
108
+ */
109
+ export function legendRows(itemWidths: readonly number[], itemHeight: number, maxWidth: number, column = false): LegendRows {
110
+ const items: Rect[] = [];
111
+ let x = 0;
112
+ let y = 0;
113
+ let rowWidth = 0;
114
+ let widest = 0;
115
+ for (let i = 0; i < itemWidths.length; i++) {
116
+ const width = itemWidths[i] as number;
117
+ const wraps = column ? i > 0 : x > 0 && x + width > maxWidth;
118
+ if (wraps) {
119
+ widest = Math.max(widest, rowWidth);
120
+ x = 0;
121
+ y += itemHeight + CHART_LEGEND_ROW_SPACING;
122
+ }
123
+ items.push({ x, y, width, height: itemHeight });
124
+ x += width;
125
+ rowWidth = x;
126
+ x += CHART_LEGEND_ITEM_SPACING;
127
+ }
128
+ widest = Math.max(widest, rowWidth);
129
+ const height = itemWidths.length ? y + itemHeight : 0;
130
+ return { size: { width: widest, height }, items };
131
+ }
132
+
133
+ /**
134
+ * Split the chart's box into the legend (a row at the top / bottom or a
135
+ * column at the leading / trailing edge), the axis bands and the plot, and
136
+ * position every text and view label.
137
+ */
138
+ export function layoutChart(input: ChartLayoutInput): ChartGeometry {
139
+ const { width, height, xAxis, yAxis } = input;
140
+ // 1. Legend.
141
+ let area: Rect = { x: 0, y: 0, width, height };
142
+ let legend: Rect | null = null;
143
+ let legendItems: Rect[] = [];
144
+ if (input.legend && input.legend.itemWidths.length) {
145
+ const { position, itemWidths, itemHeight } = input.legend;
146
+ const column = position === 'leading' || position === 'trailing';
147
+ const rows = legendRows(itemWidths, itemHeight, column ? Infinity : width, column);
148
+ if (column) {
149
+ const w = Math.min(rows.size.width, width);
150
+ const x = position === 'leading' ? 0 : width - w;
151
+ legend = { x, y: 0, width: w, height: Math.min(rows.size.height, height) };
152
+ const taken = w + CHART_LEGEND_MARGIN;
153
+ area = { x: position === 'leading' ? taken : 0, y: 0, width: Math.max(0, width - taken), height };
154
+ } else {
155
+ const h = Math.min(rows.size.height, height);
156
+ const y = position === 'top' ? 0 : height - h;
157
+ legend = { x: 0, y, width, height: h };
158
+ const taken = h + CHART_LEGEND_MARGIN;
159
+ area = { x: 0, y: position === 'top' ? taken : 0, width, height: Math.max(0, height - taken) };
160
+ }
161
+ legendItems = rows.items.map((r) => ({ x: legend!.x + r.x, y: legend!.y + r.y, width: r.width, height: r.height }));
162
+ }
163
+
164
+ // 2. Axis bands and the plot.
165
+ const xBandH = Math.min(axisBandThickness(xAxis, 'height'), area.height);
166
+ const yBandW = Math.min(axisBandThickness(yAxis, 'width'), area.width);
167
+ const xTop = xAxis?.position === 'top';
168
+ const yLeading = yAxis?.position === 'leading';
169
+ const plot: Rect = {
170
+ x: area.x + (yLeading ? yBandW : 0),
171
+ y: area.y + (xTop ? xBandH : 0),
172
+ width: Math.max(0, area.width - yBandW),
173
+ height: Math.max(0, area.height - xBandH),
174
+ };
175
+ const xBand: Rect | null = xAxis
176
+ ? { x: plot.x, y: xTop ? area.y : plot.y + plot.height, width: plot.width, height: xBandH }
177
+ : null;
178
+ const yBand: Rect | null = yAxis
179
+ ? { x: yLeading ? area.x : plot.x + plot.width, y: plot.y, width: yBandW, height: plot.height }
180
+ : null;
181
+
182
+ // 3. Labels.
183
+ const xLabels: Rect[] = [];
184
+ const xViews: Rect[] = [];
185
+ if (xAxis) {
186
+ const top = (h: number) => (xTop ? plot.y - CHART_AXIS_PAD - h : plot.y + plot.height + CHART_AXIS_PAD);
187
+ for (const label of xAxis.labels) {
188
+ const h = xAxis.labelHeight;
189
+ const x = clampTo(mapX(plot, label.at) - label.width / 2, label.width, 0, width);
190
+ xLabels.push({ x, y: top(h), width: label.width, height: h });
191
+ }
192
+ for (const view of xAxis.views) {
193
+ const { width: w, height: h } = view.size;
194
+ xViews.push({ x: mapX(plot, view.at) - w / 2, y: top(h), width: w, height: h });
195
+ }
196
+ }
197
+ const yLabels: Rect[] = [];
198
+ const yViews: Rect[] = [];
199
+ if (yAxis) {
200
+ const left = (w: number) => (yLeading ? plot.x - CHART_AXIS_PAD - w : plot.x + plot.width + CHART_AXIS_PAD);
201
+ for (const label of yAxis.labels) {
202
+ const h = yAxis.labelHeight;
203
+ const y = clampTo(mapY(plot, label.at) - h / 2, h, 0, height);
204
+ yLabels.push({ x: left(label.width), y, width: label.width, height: h });
205
+ }
206
+ for (const view of yAxis.views) {
207
+ const { width: w, height: h } = view.size;
208
+ yViews.push({ x: left(w), y: mapY(plot, view.at) - h / 2, width: w, height: h });
209
+ }
210
+ }
211
+
212
+ return { plot, legend, xBand, yBand, xLabels, yLabels, xViews, yViews, legendItems };
213
+ }
214
+
215
+ /** Keep `[start, start + length]` inside `[min, max]` when it fits (else pin it to `min`). */
216
+ function clampTo(start: number, length: number, min: number, max: number): number {
217
+ if (length >= max - min) return min;
218
+ return Math.min(max - length, Math.max(min, start));
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Unit → pixel mapping
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export function mapX(plot: Rect, u: number): number {
226
+ return plot.x + fin(u) * plot.width;
227
+ }
228
+
229
+ /** `y` runs top → bottom like every box in the protocol. */
230
+ export function mapY(plot: Rect, u: number): number {
231
+ return plot.y + fin(u) * plot.height;
232
+ }
233
+
234
+ /** The pixel rect of a unit rect (normalized so width/height are non-negative). */
235
+ export function unitRect(plot: Rect, x0: number, x1: number, y0: number, y1: number): Rect {
236
+ const left = mapX(plot, Math.min(fin(x0), fin(x1)));
237
+ const right = mapX(plot, Math.max(fin(x0), fin(x1)));
238
+ const top = mapY(plot, Math.min(fin(y0), fin(y1)));
239
+ const bottom = mapY(plot, Math.max(fin(y0), fin(y1)));
240
+ return { x: left, y: top, width: right - left, height: bottom - top };
241
+ }
242
+
243
+ /** A `rect` mark's pixel rect: the unit rect, or `fixedWidth` px centered on the band. */
244
+ export function rectMarkRect(
245
+ plot: Rect,
246
+ mark: { x0: number; x1: number; y0: number; y1: number; fixedWidth?: number | null },
247
+ ): Rect {
248
+ const r = unitRect(plot, mark.x0, mark.x1, mark.y0, mark.y1);
249
+ const fixed = mark.fixedWidth;
250
+ if (fixed != null && Number.isFinite(fixed) && fixed >= 0) {
251
+ return { x: r.x + (r.width - fixed) / 2, y: r.y, width: fixed, height: r.height };
252
+ }
253
+ return r;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Annotations
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** Unit anchors (0 leading/top, 0.5 center, 1 trailing/bottom) of an `Alignment`. */
261
+ export function alignmentAnchors(alignment: string | null | undefined): [number, number] {
262
+ switch (alignment) {
263
+ case 'leading':
264
+ return [0, 0.5];
265
+ case 'trailing':
266
+ return [1, 0.5];
267
+ case 'top':
268
+ return [0.5, 0];
269
+ case 'bottom':
270
+ return [0.5, 1];
271
+ case 'topLeading':
272
+ return [0, 0];
273
+ case 'topTrailing':
274
+ return [1, 0];
275
+ case 'bottomLeading':
276
+ return [0, 1];
277
+ case 'bottomTrailing':
278
+ return [1, 1];
279
+ default:
280
+ return [0.5, 0.5];
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Where an annotation of `size` goes against its mark's pixel rect `R`:
286
+ * `top` / `bottom` above / below it (`spacing` apart), `leading` / `trailing`
287
+ * beside it, `overlay` on it; `alignment` positions it along the free axis
288
+ * like a `ZStack` alignment would inside `R`.
289
+ */
290
+ export function annotationFrame(
291
+ R: Rect,
292
+ size: Size,
293
+ position: ChartItemPosition,
294
+ alignment: string | null | undefined,
295
+ spacing: number,
296
+ ): Rect {
297
+ const [ax, ay] = alignmentAnchors(alignment);
298
+ const gap = fin(spacing);
299
+ const alongX = R.x + (R.width - size.width) * ax;
300
+ const alongY = R.y + (R.height - size.height) * ay;
301
+ switch (position) {
302
+ case 'top':
303
+ return { x: alongX, y: R.y - gap - size.height, ...size };
304
+ case 'bottom':
305
+ return { x: alongX, y: R.y + R.height + gap, ...size };
306
+ case 'leading':
307
+ return { x: R.x - gap - size.width, y: alongY, ...size };
308
+ case 'trailing':
309
+ return { x: R.x + R.width + gap, y: alongY, ...size };
310
+ default:
311
+ return { x: alongX, y: alongY, ...size };
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Paths
317
+ // ---------------------------------------------------------------------------
318
+
319
+ export interface Point {
320
+ x: number;
321
+ y: number;
322
+ }
323
+
324
+ export type Interpolation = 'linear' | 'cardinal' | 'catmullRom' | 'monotone' | 'stepStart' | 'stepCenter' | 'stepEnd';
325
+
326
+ const n = (v: number): string => String(Math.round(fin(v) * 100) / 100);
327
+
328
+ /**
329
+ * SVG path data through `points` with `interpolation`, starting with `M`
330
+ * (empty for no points). `cardinal`, `catmullRom` and `monotone` are all drawn
331
+ * as a uniform Catmull-Rom spline (one `C` per interval); the steps are
332
+ * right-angle paths.
333
+ */
334
+ export function linePath(points: readonly Point[], interpolation: Interpolation | string | undefined): string {
335
+ if (points.length === 0) return '';
336
+ const [first, ...rest] = points as [Point, ...Point[]];
337
+ if (points.length === 1) return `M${n(first.x)} ${n(first.y)}`;
338
+ switch (interpolation) {
339
+ case 'cardinal':
340
+ case 'catmullRom':
341
+ case 'monotone':
342
+ return catmullRomPath(points);
343
+ case 'stepStart':
344
+ case 'stepCenter':
345
+ case 'stepEnd': {
346
+ let d = `M${n(first.x)} ${n(first.y)}`;
347
+ let prev = first;
348
+ for (const p of rest) {
349
+ if (interpolation === 'stepStart') d += `V${n(p.y)}H${n(p.x)}`;
350
+ else if (interpolation === 'stepEnd') d += `H${n(p.x)}V${n(p.y)}`;
351
+ else {
352
+ const mid = (prev.x + p.x) / 2;
353
+ d += `H${n(mid)}V${n(p.y)}H${n(p.x)}`;
354
+ }
355
+ prev = p;
356
+ }
357
+ return d;
358
+ }
359
+ default:
360
+ return `M${n(first.x)} ${n(first.y)}` + rest.map((p) => `L${n(p.x)} ${n(p.y)}`).join('');
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Uniform Catmull-Rom spline through `points` as cubic Béziers: for the
366
+ * interval P1→P2 with neighbours P0 and P3 (clamped at the ends) the control
367
+ * points are P1 + (P2 − P0) / 6 and P2 − (P3 − P1) / 6.
368
+ */
369
+ export function catmullRomPath(points: readonly Point[]): string {
370
+ if (points.length === 0) return '';
371
+ const first = points[0] as Point;
372
+ let d = `M${n(first.x)} ${n(first.y)}`;
373
+ for (let i = 0; i < points.length - 1; i++) {
374
+ const p0 = points[Math.max(0, i - 1)] as Point;
375
+ const p1 = points[i] as Point;
376
+ const p2 = points[i + 1] as Point;
377
+ const p3 = points[Math.min(points.length - 1, i + 2)] as Point;
378
+ const c1x = p1.x + (p2.x - p0.x) / 6;
379
+ const c1y = p1.y + (p2.y - p0.y) / 6;
380
+ const c2x = p2.x - (p3.x - p1.x) / 6;
381
+ const c2y = p2.y - (p3.y - p1.y) / 6;
382
+ d += `C${n(c1x)} ${n(c1y)} ${n(c2x)} ${n(c2y)} ${n(p2.x)} ${n(p2.y)}`;
383
+ }
384
+ return d;
385
+ }
386
+
387
+ /**
388
+ * A closed path between the upper curve (`y0`) and the lower curve (`y1`),
389
+ * both drawn with `interpolation`: the upper curve forward, then the lower
390
+ * one backward.
391
+ */
392
+ export function areaPath(points: readonly { x: number; y0: number; y1: number }[], interpolation: Interpolation | string | undefined): string {
393
+ if (points.length === 0) return '';
394
+ const upper = points.map((p) => ({ x: p.x, y: p.y0 }));
395
+ const lower = points.map((p) => ({ x: p.x, y: p.y1 })).reverse();
396
+ const top = linePath(upper, interpolation);
397
+ // The lower curve continues from the upper one's end: drop its `M`. Walked
398
+ // backwards, a step that rises at the start rises at the end.
399
+ const reversed = interpolation === 'stepStart' ? 'stepEnd' : interpolation === 'stepEnd' ? 'stepStart' : interpolation;
400
+ const bottom = linePath(lower, reversed);
401
+ const start = lower[0] as Point;
402
+ return `${top}L${n(start.x)} ${n(start.y)}${bottom.replace(/^M[^A-Za-z]*/, '')}Z`;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Symbols
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export type SymbolName = 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'asterisk';
410
+
411
+ export interface SymbolPath {
412
+ d: string;
413
+ /** True for the line glyphs (`cross`, `plus`, `asterisk`): stroked with `strokeWidth`, not filled. */
414
+ stroked: boolean;
415
+ strokeWidth: number;
416
+ }
417
+
418
+ /** Path data for a `size`-px symbol glyph centered on (`cx`, `cy`). Unknown names draw a circle. */
419
+ export function symbolPath(symbol: string | null | undefined, cx: number, cy: number, size: number): SymbolPath {
420
+ const r = Math.max(0, fin(size)) / 2;
421
+ const strokeWidth = Math.max(1, r / 3);
422
+ switch (symbol) {
423
+ case 'square':
424
+ return { d: `M${n(cx - r)} ${n(cy - r)}h${n(2 * r)}v${n(2 * r)}h${n(-2 * r)}Z`, stroked: false, strokeWidth };
425
+ case 'triangle': {
426
+ // Equilateral-ish: apex up, base at the bottom of the box.
427
+ const h = 2 * r;
428
+ return { d: `M${n(cx)} ${n(cy - r)}L${n(cx + r)} ${n(cy - r + h)}L${n(cx - r)} ${n(cy - r + h)}Z`, stroked: false, strokeWidth };
429
+ }
430
+ case 'diamond':
431
+ return {
432
+ d: `M${n(cx)} ${n(cy - r)}L${n(cx + r)} ${n(cy)}L${n(cx)} ${n(cy + r)}L${n(cx - r)} ${n(cy)}Z`,
433
+ stroked: false,
434
+ strokeWidth,
435
+ };
436
+ case 'cross': {
437
+ const k = r * Math.SQRT1_2;
438
+ return {
439
+ d: `M${n(cx - k)} ${n(cy - k)}L${n(cx + k)} ${n(cy + k)}M${n(cx + k)} ${n(cy - k)}L${n(cx - k)} ${n(cy + k)}`,
440
+ stroked: true,
441
+ strokeWidth,
442
+ };
443
+ }
444
+ case 'plus':
445
+ return {
446
+ d: `M${n(cx)} ${n(cy - r)}V${n(cy + r)}M${n(cx - r)} ${n(cy)}H${n(cx + r)}`,
447
+ stroked: true,
448
+ strokeWidth,
449
+ };
450
+ case 'asterisk': {
451
+ const k = r * Math.SQRT1_2;
452
+ return {
453
+ d:
454
+ `M${n(cx)} ${n(cy - r)}V${n(cy + r)}M${n(cx - r)} ${n(cy)}H${n(cx + r)}` +
455
+ `M${n(cx - k)} ${n(cy - k)}L${n(cx + k)} ${n(cy + k)}M${n(cx + k)} ${n(cy - k)}L${n(cx - k)} ${n(cy + k)}`,
456
+ stroked: true,
457
+ strokeWidth,
458
+ };
459
+ }
460
+ default:
461
+ // Two arcs make a full circle.
462
+ return {
463
+ d: `M${n(cx - r)} ${n(cy)}a${n(r)} ${n(r)} 0 1 0 ${n(2 * r)} 0a${n(r)} ${n(r)} 0 1 0 ${n(-2 * r)} 0Z`,
464
+ stroked: false,
465
+ strokeWidth,
466
+ };
467
+ }
468
+ }
469
+
470
+ /** The glyph box of a legend item: a `symbol` square or the 18 × 3 line. */
471
+ export function legendGlyphSize(symbol: string | null | undefined): Size {
472
+ return symbol ? { width: CHART_LEGEND_GLYPH, height: CHART_LEGEND_GLYPH } : CHART_LEGEND_LINE;
473
+ }
474
+
475
+ export function fin(v: unknown): number {
476
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
477
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Phase 8 control helpers shared by the layout engine and the renderer
3
+ * (docs/ops-protocol.md, "Phase 8: interaction", Controls). Pure and DOM-free:
4
+ * slider value snapping and the US-English UTC formatting of a `datepicker`.
5
+ */
6
+
7
+ // Slider
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Decimal places of `step` (0.25 → 2), capped so float noise never leaks into the JSON. */
11
+ function decimalsOf(step: number): number {
12
+ const text = String(step);
13
+ const exp = /e-(\d+)$/.exec(text);
14
+ if (exp) return Math.min(10, Number(exp[1]));
15
+ const dot = text.indexOf('.');
16
+ return dot < 0 ? 0 : Math.min(10, text.length - dot - 1);
17
+ }
18
+
19
+ /**
20
+ * The slider value for a thumb fraction (0 at `min`, 1 at `max`): snapped to
21
+ * the nearest multiple of `step` from `min` (when `step` is positive) and
22
+ * clamped to `min…max`. A degenerate range (`max <= min`) yields `min`.
23
+ */
24
+ export function snapSliderValue(fraction: number, min: number, max: number, step: number | null): number {
25
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return Number.isFinite(min) ? min : 0;
26
+ const f = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
27
+ let value = min + f * (max - min);
28
+ if (step !== null && Number.isFinite(step) && step > 0) {
29
+ value = min + Math.round((value - min) / step) * step;
30
+ value = Number(value.toFixed(decimalsOf(step)));
31
+ } else {
32
+ value = Math.round(value * 1e6) / 1e6;
33
+ }
34
+ return Math.min(max, Math.max(min, value));
35
+ }
36
+
37
+ /** The thumb's position for `value`: 0 at `min`, 1 at `max`, clamped. */
38
+ export function sliderFraction(value: number, min: number, max: number): number {
39
+ if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 0;
40
+ return Math.min(1, Math.max(0, (value - min) / (max - min)));
41
+ }
42
+
43
+ // Date picker
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
47
+
48
+ function dateOf(seconds: number): Date {
49
+ return new Date((Number.isFinite(seconds) ? seconds : 0) * 1000);
50
+ }
51
+
52
+ function pad2(n: number): string {
53
+ return n < 10 ? `0${n}` : String(n);
54
+ }
55
+
56
+ /** "Oct 3, 2026": the UTC date of `seconds` in the US English abbreviated form. */
57
+ export function formatPickerDate(seconds: number): string {
58
+ const d = dateOf(seconds);
59
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
60
+ }
61
+
62
+ /** "7:00 AM": the UTC time of `seconds` in the US English 12-hour form. */
63
+ export function formatPickerTime(seconds: number): string {
64
+ const d = dateOf(seconds);
65
+ const hours = d.getUTCHours();
66
+ const period = hours < 12 ? 'AM' : 'PM';
67
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
68
+ return `${hour12}:${pad2(d.getUTCMinutes())} ${period}`;
69
+ }
70
+
71
+ /** `yyyy-mm-dd` for a native `<input type="date">`, in UTC. */
72
+ export function dateInputValue(seconds: number): string {
73
+ const d = dateOf(seconds);
74
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
75
+ }
76
+
77
+ /** `HH:MM` for a native `<input type="time">`, in UTC. */
78
+ export function timeInputValue(seconds: number): string {
79
+ const d = dateOf(seconds);
80
+ return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
81
+ }
82
+
83
+ /**
84
+ * Seconds (UTC) after a native input changed: the `date` (`yyyy-mm-dd`)
85
+ * replaces the day and keeps the time of day of `seconds`; the `time`
86
+ * (`HH:MM`) replaces the time of day and keeps the day. Seconds within the
87
+ * minute are dropped, like the pickers' minute granularity. An unparsable
88
+ * value leaves `seconds` as it was.
89
+ */
90
+ export function combineDateSeconds(seconds: number, change: { date?: string; time?: string }): number {
91
+ const d = dateOf(seconds);
92
+ let year = d.getUTCFullYear();
93
+ let month = d.getUTCMonth();
94
+ let day = d.getUTCDate();
95
+ let hours = d.getUTCHours();
96
+ let minutes = d.getUTCMinutes();
97
+ if (change.date !== undefined) {
98
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(change.date);
99
+ if (!m) return seconds;
100
+ year = Number(m[1]);
101
+ month = Number(m[2]) - 1;
102
+ day = Number(m[3]);
103
+ }
104
+ if (change.time !== undefined) {
105
+ const m = /^(\d{2}):(\d{2})/.exec(change.time);
106
+ if (!m) return seconds;
107
+ hours = Number(m[1]);
108
+ minutes = Number(m[2]);
109
+ }
110
+ return Math.floor(Date.UTC(year, month, day, hours, minutes, 0) / 1000);
111
+ }
112
+
113
+ /** True when two instants fall on the same UTC day (a time input's `min`/`max` only apply then). */
114
+ export function sameUTCDay(a: number, b: number): boolean {
115
+ return dateInputValue(a) === dateInputValue(b);
116
+ }