@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.
- package/README.md +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wasm.ts +400 -0
package/src/chartDom.ts
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7: the chrome of a `chart` element (docs/ops-protocol.md, "Phase 7:
|
|
3
|
+
* charts").
|
|
4
|
+
*
|
|
5
|
+
* The engine (src/layout/chart.ts) hands over the plot rectangle, the axis
|
|
6
|
+
* bands, the legend item rects and the text label rects as `chrome`, and the
|
|
7
|
+
* label texts as `chromeText`; this module turns the chart's props into DOM
|
|
8
|
+
* inside its `.sb-chart-chrome` container:
|
|
9
|
+
*
|
|
10
|
+
* - one `<svg class="sb-chart-plot">` the size of the plot (`overflow:
|
|
11
|
+
* hidden`) holding the grid lines and the marks in array order, with
|
|
12
|
+
* `<defs>` for gradient fills and `clipPath`s for masks;
|
|
13
|
+
* - 1 × 4 px tick marks outside the plot;
|
|
14
|
+
* - the axis text labels (`.sb-chart-label`, caption font, secondary color);
|
|
15
|
+
* - the legend items (`.sb-chart-legend-item`: a glyph and a caption label).
|
|
16
|
+
*
|
|
17
|
+
* Everything is rebuilt whenever the props or the geometry change (an
|
|
18
|
+
* `update` with new `marks` redraws at the next commit); nothing animates.
|
|
19
|
+
* `chartitem` children are ordinary slot children the engine frames.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { LayoutNode, LayoutResult, Rect, ResolvedFont } from './layout';
|
|
23
|
+
import {
|
|
24
|
+
CHART_TICK_LENGTH,
|
|
25
|
+
areaPath,
|
|
26
|
+
legendGlyphSize,
|
|
27
|
+
linePath,
|
|
28
|
+
mapX,
|
|
29
|
+
mapY,
|
|
30
|
+
rectMarkRect,
|
|
31
|
+
symbolPath,
|
|
32
|
+
unitRect,
|
|
33
|
+
} from './layout/chart';
|
|
34
|
+
import {
|
|
35
|
+
isColor,
|
|
36
|
+
isColorGradientStyle,
|
|
37
|
+
isHierarchicalStyle,
|
|
38
|
+
isLinearGradientStyle,
|
|
39
|
+
isMaterialStyle,
|
|
40
|
+
isRadialGradientStyle,
|
|
41
|
+
type ChartAxis,
|
|
42
|
+
type ChartLegend,
|
|
43
|
+
type ChartMark,
|
|
44
|
+
type ChartProps,
|
|
45
|
+
type GradientStop,
|
|
46
|
+
type ShapeStyle,
|
|
47
|
+
} from './protocol';
|
|
48
|
+
import { MATERIAL_ALPHA, colorToCSS, styleToCSS } from './styles';
|
|
49
|
+
|
|
50
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
51
|
+
const LINE_WIDTH_DEFAULT = 2;
|
|
52
|
+
const RULE_WIDTH_DEFAULT = 1;
|
|
53
|
+
const SYMBOL_SIZE_DEFAULT = 8;
|
|
54
|
+
/** Materials paint as a translucent system gray in SVG (no backdrop blur there). */
|
|
55
|
+
const MATERIAL_GRAY = '120, 120, 128';
|
|
56
|
+
|
|
57
|
+
/** Signature of the last chrome drawn per chart element, to skip unchanged redraws. */
|
|
58
|
+
const DRAWN = new WeakMap<HTMLElement, string>();
|
|
59
|
+
|
|
60
|
+
/** Draw (or redraw) a chart's chrome from its props and the engine's rects. */
|
|
61
|
+
export function applyChartChrome(el: HTMLElement, node: LayoutNode, result: LayoutResult, box: Rect): void {
|
|
62
|
+
const container = el.querySelector<HTMLElement>(':scope > .sb-chart-chrome');
|
|
63
|
+
if (!container) return;
|
|
64
|
+
const chrome = result.chrome.get(node.id);
|
|
65
|
+
const plot = chrome?.['plot'];
|
|
66
|
+
if (!chrome || !plot) {
|
|
67
|
+
container.replaceChildren();
|
|
68
|
+
DRAWN.delete(el);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const texts = result.chromeText.get(node.id) ?? {};
|
|
72
|
+
const font = result.fonts.get(node.id);
|
|
73
|
+
const signature = JSON.stringify([node.props, chrome, texts, font?.css ?? '', box.x, box.y]);
|
|
74
|
+
if (DRAWN.get(el) === signature) return;
|
|
75
|
+
DRAWN.set(el, signature);
|
|
76
|
+
|
|
77
|
+
const props = node.props as unknown as Partial<ChartProps>;
|
|
78
|
+
const doc = el.ownerDocument;
|
|
79
|
+
const idPrefix = `sb-chart-${node.id}`;
|
|
80
|
+
const nodes: Element[] = [];
|
|
81
|
+
|
|
82
|
+
nodes.push(buildPlot(doc, props, plot, box, idPrefix));
|
|
83
|
+
nodes.push(...buildTicks(doc, props, plot, box));
|
|
84
|
+
for (const key of Object.keys(chrome)) {
|
|
85
|
+
if (!key.startsWith('xlabel') && !key.startsWith('ylabel')) continue;
|
|
86
|
+
const rect = chrome[key] as Rect;
|
|
87
|
+
const layout = texts[key];
|
|
88
|
+
if (!layout) continue;
|
|
89
|
+
const label = doc.createElement('span');
|
|
90
|
+
label.className = 'sb-chart-label';
|
|
91
|
+
label.dataset['sbAxis'] = key.startsWith('x') ? 'x' : 'y';
|
|
92
|
+
label.style.font = layout.font.css;
|
|
93
|
+
label.style.textAlign = layout.alignment === 'trailing' ? 'right' : layout.alignment === 'center' ? 'center' : 'left';
|
|
94
|
+
label.style.lineHeight = `${rect.height}px`;
|
|
95
|
+
label.textContent = layout.lines.join('\n');
|
|
96
|
+
position(label, rect, box);
|
|
97
|
+
nodes.push(label);
|
|
98
|
+
}
|
|
99
|
+
nodes.push(...buildLegend(doc, props.legend ?? null, chrome, box, font, idPrefix));
|
|
100
|
+
container.replaceChildren(...nodes);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function position(el: HTMLElement | SVGElement, rect: Rect, origin: Rect): void {
|
|
104
|
+
const s = el.style;
|
|
105
|
+
s.position = 'absolute';
|
|
106
|
+
s.left = `${rect.x - origin.x}px`;
|
|
107
|
+
s.top = `${rect.y - origin.y}px`;
|
|
108
|
+
s.width = `${rect.width}px`;
|
|
109
|
+
s.height = `${rect.height}px`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function svg<K extends keyof SVGElementTagNameMap>(doc: Document, name: K, attrs: Record<string, string | number> = {}): SVGElementTagNameMap[K] {
|
|
113
|
+
const el = doc.createElementNS(SVG_NS, name);
|
|
114
|
+
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value));
|
|
115
|
+
return el;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const r2 = (v: number): number => Math.round(v * 100) / 100;
|
|
119
|
+
const fin = (v: unknown, fallback: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// The plot SVG
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function buildPlot(doc: Document, props: Partial<ChartProps>, plot: Rect, box: Rect, idPrefix: string): SVGSVGElement {
|
|
126
|
+
const w = Math.max(0, plot.width);
|
|
127
|
+
const h = Math.max(0, plot.height);
|
|
128
|
+
const root = svg(doc, 'svg', { class: 'sb-chart-plot', width: w, height: h, viewBox: `0 0 ${w} ${h}` });
|
|
129
|
+
position(root, plot, box);
|
|
130
|
+
const defs = svg(doc, 'defs');
|
|
131
|
+
root.append(defs);
|
|
132
|
+
// Marks are laid out in the SVG's own coordinates: the plot at (0, 0).
|
|
133
|
+
const local: Rect = { x: 0, y: 0, width: w, height: h };
|
|
134
|
+
|
|
135
|
+
const grid = svg(doc, 'g', { class: 'sb-chart-grid' });
|
|
136
|
+
for (const u of gridLines(props.xAxis)) {
|
|
137
|
+
const x = r2(Math.floor(mapX(local, u)) + 0.5);
|
|
138
|
+
grid.append(svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'x', x1: x, y1: 0, x2: x, y2: h }));
|
|
139
|
+
}
|
|
140
|
+
for (const u of gridLines(props.yAxis)) {
|
|
141
|
+
const y = r2(Math.floor(mapY(local, u)) + 0.5);
|
|
142
|
+
grid.append(svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'y', x1: 0, y1: y, x2: w, y2: y }));
|
|
143
|
+
}
|
|
144
|
+
root.append(grid);
|
|
145
|
+
|
|
146
|
+
const marks = svg(doc, 'g', { class: 'sb-chart-marks' });
|
|
147
|
+
const ids = { next: 0, prefix: idPrefix };
|
|
148
|
+
const list = Array.isArray(props.marks) ? props.marks : [];
|
|
149
|
+
list.forEach((mark) => {
|
|
150
|
+
if (!mark || typeof mark !== 'object') return;
|
|
151
|
+
const el = buildMark(doc, defs, mark, local, ids);
|
|
152
|
+
if (el) marks.append(el);
|
|
153
|
+
});
|
|
154
|
+
root.append(marks);
|
|
155
|
+
return root;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function gridLines(axis: ChartAxis | null | undefined): number[] {
|
|
159
|
+
return axis && Array.isArray(axis.gridLines) ? axis.gridLines.filter((v) => typeof v === 'number' && Number.isFinite(v)) : [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface IdSource {
|
|
163
|
+
next: number;
|
|
164
|
+
prefix: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function nextId(ids: IdSource, kind: string): string {
|
|
168
|
+
return `${ids.prefix}-${kind}${ids.next++}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildMark(doc: Document, defs: SVGDefsElement, mark: ChartMark, plot: Rect, ids: IdSource): SVGElement | null {
|
|
172
|
+
let el: SVGElement | null;
|
|
173
|
+
let bbox: Rect;
|
|
174
|
+
switch (mark.kind) {
|
|
175
|
+
case 'rect': {
|
|
176
|
+
bbox = rectMarkRect(plot, mark);
|
|
177
|
+
const radius = Math.max(0, fin(mark.cornerRadius, 0));
|
|
178
|
+
el = svg(doc, 'rect', {
|
|
179
|
+
x: r2(bbox.x),
|
|
180
|
+
y: r2(bbox.y),
|
|
181
|
+
width: r2(Math.max(0, bbox.width)),
|
|
182
|
+
height: r2(Math.max(0, bbox.height)),
|
|
183
|
+
rx: r2(radius),
|
|
184
|
+
ry: r2(radius),
|
|
185
|
+
});
|
|
186
|
+
el.style.fill = paintFor(doc, defs, mark.style, bbox, ids);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case 'line': {
|
|
190
|
+
const points = (Array.isArray(mark.points) ? mark.points : [])
|
|
191
|
+
.filter((p) => p && typeof p === 'object')
|
|
192
|
+
.map((p) => ({ x: mapX(plot, fin(p.x, 0)), y: mapY(plot, fin(p.y, 0)) }));
|
|
193
|
+
bbox = boundsOf(points, plot);
|
|
194
|
+
const paint = paintFor(doc, defs, mark.style, bbox, ids);
|
|
195
|
+
const group = svg(doc, 'g');
|
|
196
|
+
const path = svg(doc, 'path', {
|
|
197
|
+
class: 'sb-chart-line',
|
|
198
|
+
d: linePath(points, mark.interpolation),
|
|
199
|
+
fill: 'none',
|
|
200
|
+
'stroke-width': r2(Math.max(0, fin(mark.lineWidth, LINE_WIDTH_DEFAULT))),
|
|
201
|
+
'stroke-linecap': 'round',
|
|
202
|
+
'stroke-linejoin': 'round',
|
|
203
|
+
});
|
|
204
|
+
path.style.stroke = paint;
|
|
205
|
+
group.append(path);
|
|
206
|
+
if (mark.symbol) {
|
|
207
|
+
const size = fin(mark.symbolSize, SYMBOL_SIZE_DEFAULT);
|
|
208
|
+
for (const p of points) group.append(symbolEl(doc, mark.symbol, p.x, p.y, size, paint));
|
|
209
|
+
}
|
|
210
|
+
el = group;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'area': {
|
|
214
|
+
const points = (Array.isArray(mark.points) ? mark.points : [])
|
|
215
|
+
.filter((p) => p && typeof p === 'object')
|
|
216
|
+
.map((p) => ({ x: mapX(plot, fin(p.x, 0)), y0: mapY(plot, fin(p.y0, 0)), y1: mapY(plot, fin(p.y1, 0)) }));
|
|
217
|
+
bbox = boundsOf(points.flatMap((p) => [{ x: p.x, y: p.y0 }, { x: p.x, y: p.y1 }]), plot);
|
|
218
|
+
el = svg(doc, 'path', { class: 'sb-chart-area', d: areaPath(points, mark.interpolation) });
|
|
219
|
+
el.style.fill = paintFor(doc, defs, mark.style, bbox, ids);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'point': {
|
|
223
|
+
const size = fin(mark.symbolSize, SYMBOL_SIZE_DEFAULT);
|
|
224
|
+
const cx = mapX(plot, fin(mark.x, 0));
|
|
225
|
+
const cy = mapY(plot, fin(mark.y, 0));
|
|
226
|
+
bbox = { x: cx - size / 2, y: cy - size / 2, width: size, height: size };
|
|
227
|
+
el = symbolEl(doc, mark.symbol, cx, cy, size, paintFor(doc, defs, mark.style, bbox, ids));
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'rule': {
|
|
231
|
+
bbox = unitRect(plot, fin(mark.x0, 0), fin(mark.x1, 0), fin(mark.y0, 0), fin(mark.y1, 0));
|
|
232
|
+
el = svg(doc, 'line', {
|
|
233
|
+
class: 'sb-chart-rule',
|
|
234
|
+
x1: r2(mapX(plot, fin(mark.x0, 0))),
|
|
235
|
+
y1: r2(mapY(plot, fin(mark.y0, 0))),
|
|
236
|
+
x2: r2(mapX(plot, fin(mark.x1, 0))),
|
|
237
|
+
y2: r2(mapY(plot, fin(mark.y1, 0))),
|
|
238
|
+
'stroke-width': r2(Math.max(0, fin(mark.lineWidth, RULE_WIDTH_DEFAULT))),
|
|
239
|
+
'stroke-linecap': 'butt',
|
|
240
|
+
});
|
|
241
|
+
el.style.stroke = paintFor(doc, defs, mark.style, bbox, ids);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
el.classList.add('sb-chart-mark');
|
|
248
|
+
el.dataset['sbMark'] = mark.kind;
|
|
249
|
+
const opacity = fin(mark.opacity, 1);
|
|
250
|
+
if (opacity < 1) el.setAttribute('opacity', String(Math.max(0, opacity)));
|
|
251
|
+
const clip = mark.mask ? clipPathFor(doc, defs, mark.mask, plot, ids) : null;
|
|
252
|
+
if (clip) el.setAttribute('clip-path', `url(#${clip})`);
|
|
253
|
+
return el;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function boundsOf(points: readonly { x: number; y: number }[], fallback: Rect): Rect {
|
|
257
|
+
if (points.length === 0) return fallback;
|
|
258
|
+
let minX = Infinity;
|
|
259
|
+
let minY = Infinity;
|
|
260
|
+
let maxX = -Infinity;
|
|
261
|
+
let maxY = -Infinity;
|
|
262
|
+
for (const p of points) {
|
|
263
|
+
minX = Math.min(minX, p.x);
|
|
264
|
+
minY = Math.min(minY, p.y);
|
|
265
|
+
maxX = Math.max(maxX, p.x);
|
|
266
|
+
maxY = Math.max(maxY, p.y);
|
|
267
|
+
}
|
|
268
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A symbol glyph: a filled path, or a stroked one for the line glyphs (cross, plus, asterisk). */
|
|
272
|
+
function symbolEl(doc: Document, symbol: string | null | undefined, cx: number, cy: number, size: number, paint: string): SVGPathElement {
|
|
273
|
+
const glyph = symbolPath(symbol, cx, cy, size);
|
|
274
|
+
const path = svg(doc, 'path', { class: 'sb-chart-symbol', d: glyph.d, 'data-sb-symbol': symbol ?? 'circle' });
|
|
275
|
+
if (glyph.stroked) {
|
|
276
|
+
path.setAttribute('fill', 'none');
|
|
277
|
+
path.setAttribute('stroke-width', String(r2(glyph.strokeWidth)));
|
|
278
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
279
|
+
path.style.stroke = paint;
|
|
280
|
+
} else {
|
|
281
|
+
path.style.fill = paint;
|
|
282
|
+
}
|
|
283
|
+
return path;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A `clipPath` holding the mask mark's geometry (an `area` path or a `rect`;
|
|
288
|
+
* other kinds clip nothing). Returns its id, or null.
|
|
289
|
+
*/
|
|
290
|
+
function clipPathFor(doc: Document, defs: SVGDefsElement, mask: ChartMark, plot: Rect, ids: IdSource): string | null {
|
|
291
|
+
if (!mask || typeof mask !== 'object') return null;
|
|
292
|
+
let shape: SVGElement;
|
|
293
|
+
if (mask.kind === 'rect') {
|
|
294
|
+
const r = rectMarkRect(plot, mask);
|
|
295
|
+
shape = svg(doc, 'rect', {
|
|
296
|
+
x: r2(r.x),
|
|
297
|
+
y: r2(r.y),
|
|
298
|
+
width: r2(Math.max(0, r.width)),
|
|
299
|
+
height: r2(Math.max(0, r.height)),
|
|
300
|
+
rx: r2(Math.max(0, fin(mask.cornerRadius, 0))),
|
|
301
|
+
});
|
|
302
|
+
} else if (mask.kind === 'area') {
|
|
303
|
+
const points = (Array.isArray(mask.points) ? mask.points : [])
|
|
304
|
+
.filter((p) => p && typeof p === 'object')
|
|
305
|
+
.map((p) => ({ x: mapX(plot, fin(p.x, 0)), y0: mapY(plot, fin(p.y0, 0)), y1: mapY(plot, fin(p.y1, 0)) }));
|
|
306
|
+
shape = svg(doc, 'path', { d: areaPath(points, mask.interpolation) });
|
|
307
|
+
} else {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const id = nextId(ids, 'clip');
|
|
311
|
+
const clip = svg(doc, 'clipPath', { id });
|
|
312
|
+
clip.append(shape);
|
|
313
|
+
defs.append(clip);
|
|
314
|
+
return id;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// ShapeStyle → SVG paint
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The SVG paint for a ShapeStyle: a CSS color for flat styles (the same
|
|
323
|
+
* tokens the renderer uses: `--sb-color-*` for semantic and named colors,
|
|
324
|
+
* the fill tokens for hierarchical levels), or `url(#gradient)` after adding
|
|
325
|
+
* a `<linearGradient>` / `<radialGradient>` to `defs`. `bbox` is the mark's
|
|
326
|
+
* pixel bounds, which a radial gradient's center and radii refer to.
|
|
327
|
+
*/
|
|
328
|
+
function paintFor(doc: Document, defs: SVGDefsElement, style: ShapeStyle | undefined, bbox: Rect, ids: IdSource): string {
|
|
329
|
+
if (!style || typeof style !== 'object') return 'var(--sb-color-accent)';
|
|
330
|
+
if (isColor(style)) return colorToCSS(style);
|
|
331
|
+
if (isHierarchicalStyle(style)) return styleToCSS(style, 'paint').value;
|
|
332
|
+
if (isMaterialStyle(style)) {
|
|
333
|
+
const alpha = (MATERIAL_ALPHA[style.material] ?? MATERIAL_ALPHA.regular) * clamp01(style.opacity ?? 1);
|
|
334
|
+
return `rgba(${MATERIAL_GRAY}, ${r2(alpha * 0.5)})`;
|
|
335
|
+
}
|
|
336
|
+
const opacity = clamp01(style.opacity ?? 1);
|
|
337
|
+
if (isLinearGradientStyle(style)) {
|
|
338
|
+
const { stops, start, end } = style.linearGradient;
|
|
339
|
+
const id = nextId(ids, 'grad');
|
|
340
|
+
const gradient = svg(doc, 'linearGradient', {
|
|
341
|
+
id,
|
|
342
|
+
gradientUnits: 'objectBoundingBox',
|
|
343
|
+
x1: r2(unit(start?.x, 0.5)),
|
|
344
|
+
y1: r2(unit(start?.y, 0)),
|
|
345
|
+
x2: r2(unit(end?.x, 0.5)),
|
|
346
|
+
y2: r2(unit(end?.y, 1)),
|
|
347
|
+
});
|
|
348
|
+
appendStops(doc, gradient, stops ?? [], opacity);
|
|
349
|
+
defs.append(gradient);
|
|
350
|
+
return `url(#${id})`;
|
|
351
|
+
}
|
|
352
|
+
if (isRadialGradientStyle(style)) {
|
|
353
|
+
const { stops, center, startRadius, endRadius } = style.radialGradient;
|
|
354
|
+
const id = nextId(ids, 'grad');
|
|
355
|
+
const cx = bbox.x + unit(center?.x, 0.5) * bbox.width;
|
|
356
|
+
const cy = bbox.y + unit(center?.y, 0.5) * bbox.height;
|
|
357
|
+
const fallbackRadius = Math.hypot(bbox.width, bbox.height) / 2;
|
|
358
|
+
const r = endRadius != null && Number.isFinite(endRadius) && endRadius > 0 ? endRadius : fallbackRadius;
|
|
359
|
+
const fr = Math.max(0, startRadius != null && Number.isFinite(startRadius) ? startRadius : 0);
|
|
360
|
+
const gradient = svg(doc, 'radialGradient', { id, gradientUnits: 'userSpaceOnUse', cx: r2(cx), cy: r2(cy), r: r2(r), fr: r2(fr) });
|
|
361
|
+
appendStops(doc, gradient, stops ?? [], opacity);
|
|
362
|
+
defs.append(gradient);
|
|
363
|
+
return `url(#${id})`;
|
|
364
|
+
}
|
|
365
|
+
if (isColorGradientStyle(style)) {
|
|
366
|
+
// `Color.gradient`: a subtle vertical gradient from a lighter to a darker shade.
|
|
367
|
+
const base = colorToCSS(style.colorGradient);
|
|
368
|
+
const id = nextId(ids, 'grad');
|
|
369
|
+
const gradient = svg(doc, 'linearGradient', { id, gradientUnits: 'objectBoundingBox', x1: 0.5, y1: 0, x2: 0.5, y2: 1 });
|
|
370
|
+
gradient.append(stopEl(doc, 0, `color-mix(in srgb, ${base}, white 12%)`, opacity));
|
|
371
|
+
gradient.append(stopEl(doc, 1, `color-mix(in srgb, ${base}, black 12%)`, opacity));
|
|
372
|
+
defs.append(gradient);
|
|
373
|
+
return `url(#${id})`;
|
|
374
|
+
}
|
|
375
|
+
return 'magenta';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function appendStops(doc: Document, gradient: SVGElement, stops: readonly GradientStop[], opacity: number): void {
|
|
379
|
+
const valid = stops.filter((stop) => stop && typeof stop === 'object' && stop.color);
|
|
380
|
+
if (valid.length === 0) {
|
|
381
|
+
gradient.append(stopEl(doc, 0, 'transparent', opacity), stopEl(doc, 1, 'transparent', opacity));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
for (const stop of valid) gradient.append(stopEl(doc, clamp01(unit(stop.location, 0)), colorToCSS(stop.color), opacity));
|
|
385
|
+
if (valid.length === 1) gradient.append(stopEl(doc, 1, colorToCSS(valid[0]!.color), opacity));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function stopEl(doc: Document, offset: number, color: string, opacity: number): SVGStopElement {
|
|
389
|
+
const stop = svg(doc, 'stop', { offset: r2(offset) });
|
|
390
|
+
stop.style.stopColor = color;
|
|
391
|
+
if (opacity < 1) stop.style.stopOpacity = String(opacity);
|
|
392
|
+
return stop;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function unit(v: number | undefined, fallback: number): number {
|
|
396
|
+
return v != null && Number.isFinite(v) ? v : fallback;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function clamp01(v: number): number {
|
|
400
|
+
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Ticks and legend
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/** 1 px ticks, 4 px long, outside the plot on the axis's side. */
|
|
408
|
+
function buildTicks(doc: Document, props: Partial<ChartProps>, plot: Rect, box: Rect): HTMLElement[] {
|
|
409
|
+
const ticks: HTMLElement[] = [];
|
|
410
|
+
const x = props.xAxis;
|
|
411
|
+
if (x && Array.isArray(x.ticks)) {
|
|
412
|
+
const top = x.position === 'top' ? plot.y - CHART_TICK_LENGTH : plot.y + plot.height;
|
|
413
|
+
for (const u of x.ticks) {
|
|
414
|
+
if (typeof u !== 'number' || !Number.isFinite(u)) continue;
|
|
415
|
+
const tick = doc.createElement('span');
|
|
416
|
+
tick.className = 'sb-chart-tick';
|
|
417
|
+
tick.dataset['sbAxis'] = 'x';
|
|
418
|
+
position(tick, { x: Math.floor(mapX(plot, u)), y: top, width: 1, height: CHART_TICK_LENGTH }, box);
|
|
419
|
+
ticks.push(tick);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const y = props.yAxis;
|
|
423
|
+
if (y && Array.isArray(y.ticks)) {
|
|
424
|
+
const left = y.position === 'leading' ? plot.x - CHART_TICK_LENGTH : plot.x + plot.width;
|
|
425
|
+
for (const u of y.ticks) {
|
|
426
|
+
if (typeof u !== 'number' || !Number.isFinite(u)) continue;
|
|
427
|
+
const tick = doc.createElement('span');
|
|
428
|
+
tick.className = 'sb-chart-tick';
|
|
429
|
+
tick.dataset['sbAxis'] = 'y';
|
|
430
|
+
position(tick, { x: left, y: Math.floor(mapY(plot, u)), width: CHART_TICK_LENGTH, height: 1 }, box);
|
|
431
|
+
ticks.push(tick);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return ticks;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** One `.sb-chart-legend-item` per legend entry at the engine's `legend<i>` rect. */
|
|
438
|
+
function buildLegend(
|
|
439
|
+
doc: Document,
|
|
440
|
+
legend: ChartLegend | null,
|
|
441
|
+
chrome: Record<string, Rect>,
|
|
442
|
+
box: Rect,
|
|
443
|
+
font: ResolvedFont | undefined,
|
|
444
|
+
idPrefix: string,
|
|
445
|
+
): HTMLElement[] {
|
|
446
|
+
const items: HTMLElement[] = [];
|
|
447
|
+
if (!legend || !Array.isArray(legend.items)) return items;
|
|
448
|
+
const ids: IdSource = { next: 0, prefix: `${idPrefix}-legend` };
|
|
449
|
+
legend.items.forEach((entry, i) => {
|
|
450
|
+
const rect = chrome[`legend${i}`];
|
|
451
|
+
if (!rect || !entry || typeof entry !== 'object') return;
|
|
452
|
+
const item = doc.createElement('div');
|
|
453
|
+
item.className = 'sb-chart-legend-item';
|
|
454
|
+
if (font) item.style.font = font.css;
|
|
455
|
+
position(item, rect, box);
|
|
456
|
+
const glyphSize = legendGlyphSize(entry.symbol);
|
|
457
|
+
const glyph = svg(doc, 'svg', {
|
|
458
|
+
class: 'sb-chart-legend-glyph',
|
|
459
|
+
width: glyphSize.width,
|
|
460
|
+
height: glyphSize.height,
|
|
461
|
+
viewBox: `0 0 ${glyphSize.width} ${glyphSize.height}`,
|
|
462
|
+
});
|
|
463
|
+
const defs = svg(doc, 'defs');
|
|
464
|
+
glyph.append(defs);
|
|
465
|
+
const bbox: Rect = { x: 0, y: 0, width: glyphSize.width, height: glyphSize.height };
|
|
466
|
+
const paint = paintFor(doc, defs, entry.style, bbox, ids);
|
|
467
|
+
if (entry.symbol) {
|
|
468
|
+
glyph.append(symbolEl(doc, entry.symbol, glyphSize.width / 2, glyphSize.height / 2, glyphSize.width, paint));
|
|
469
|
+
} else {
|
|
470
|
+
const line = svg(doc, 'rect', { x: 0, y: 0, width: glyphSize.width, height: glyphSize.height, rx: glyphSize.height / 2 });
|
|
471
|
+
line.style.fill = paint;
|
|
472
|
+
glyph.append(line);
|
|
473
|
+
}
|
|
474
|
+
const label = doc.createElement('span');
|
|
475
|
+
label.className = 'sb-chart-legend-label';
|
|
476
|
+
label.textContent = typeof entry.label === 'string' ? entry.label : String(entry.label ?? '');
|
|
477
|
+
item.append(glyph, label);
|
|
478
|
+
items.push(item);
|
|
479
|
+
});
|
|
480
|
+
return items;
|
|
481
|
+
}
|
package/src/devEvents.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Vite websocket events exchanged between the dev-server plugin
|
|
3
|
+
* (plugins/swift-wasm.ts) and the page (src/main.ts) for hot reload.
|
|
4
|
+
*
|
|
5
|
+
* This file is imported by both sides, so it must stay free of DOM and Node
|
|
6
|
+
* types.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import 'vite/types/customEvent.d.ts';
|
|
10
|
+
|
|
11
|
+
export interface BuildStartPayload {
|
|
12
|
+
/** Products being rebuilt, e.g. `["Counter", "Todos"]`. */
|
|
13
|
+
apps: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BuildEndPayload {
|
|
17
|
+
apps: string[];
|
|
18
|
+
ok: boolean;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BuildErrorPayload {
|
|
23
|
+
/** The product whose build failed. */
|
|
24
|
+
app: string;
|
|
25
|
+
/** Combined stdout + stderr of `scripts/build-wasm.sh`. */
|
|
26
|
+
output: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare module 'vite/types/customEvent.d.ts' {
|
|
30
|
+
interface CustomEventMap {
|
|
31
|
+
'sb:build-start': BuildStartPayload;
|
|
32
|
+
'sb:build-end': BuildEndPayload;
|
|
33
|
+
'sb:build-error': BuildErrorPayload;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame mode vs device mode for the app page.
|
|
3
|
+
*
|
|
4
|
+
* - Frame (desktop, tests): the app renders inside the fake iPhone bezel with
|
|
5
|
+
* a drawn status bar and the toolbar's theme / Text size controls.
|
|
6
|
+
* - Device (a real phone): the app fills the viewport, the OS draws the status
|
|
7
|
+
* bar, and the layout engine gets the real safe-area insets from
|
|
8
|
+
* `env(safe-area-inset-*)`, exposed as `--sb-inset-*` on `:root`.
|
|
9
|
+
*
|
|
10
|
+
* `?mode=device` / `?mode=frame` force a mode; otherwise a coarse pointer
|
|
11
|
+
* means a touch device at any width (Phase 7: a tablet fills the viewport
|
|
12
|
+
* instead of showing the phone bezel; the old 500 px limit is gone). The
|
|
13
|
+
* inline script in app/index.html applies the same rule before the first
|
|
14
|
+
* paint.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { EdgeInsets } from './layout';
|
|
18
|
+
|
|
19
|
+
export type PageMode = 'frame' | 'device';
|
|
20
|
+
|
|
21
|
+
export function decidePageMode(
|
|
22
|
+
search: string = location.search,
|
|
23
|
+
win: Pick<Window, 'matchMedia'> = window,
|
|
24
|
+
): PageMode {
|
|
25
|
+
const mode = new URLSearchParams(search).get('mode');
|
|
26
|
+
if (mode === 'device') return 'device';
|
|
27
|
+
if (mode === 'frame') return 'frame';
|
|
28
|
+
const coarse = typeof win.matchMedia === 'function' && win.matchMedia('(pointer: coarse)').matches;
|
|
29
|
+
return coarse ? 'device' : 'frame';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mark the document so styles.css can switch the chrome. */
|
|
33
|
+
export function applyPageMode(mode: PageMode): void {
|
|
34
|
+
document.documentElement.dataset['sbMode'] = mode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The real safe-area insets, read from the `--sb-inset-*` custom properties
|
|
39
|
+
* (`env(safe-area-inset-*)`, 0 outside standalone / landscape). Values that
|
|
40
|
+
* do not parse count as 0.
|
|
41
|
+
*/
|
|
42
|
+
export function readViewportInsets(): EdgeInsets {
|
|
43
|
+
const cs = getComputedStyle(document.documentElement);
|
|
44
|
+
const read = (name: string): number => {
|
|
45
|
+
const value = parseFloat(cs.getPropertyValue(name));
|
|
46
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
top: read('--sb-inset-top'),
|
|
50
|
+
leading: read('--sb-inset-left'),
|
|
51
|
+
bottom: read('--sb-inset-bottom'),
|
|
52
|
+
trailing: read('--sb-inset-right'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Call `onChange` (debounced) whenever the viewport may have changed size or
|
|
58
|
+
* insets: window `resize`, `orientationchange` and `visualViewport` resizes.
|
|
59
|
+
* Returns a function that stops listening.
|
|
60
|
+
*/
|
|
61
|
+
export function watchViewport(onChange: () => void, debounceMs = 100): () => void {
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
const schedule = (): void => {
|
|
64
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
timer = undefined;
|
|
67
|
+
onChange();
|
|
68
|
+
}, debounceMs);
|
|
69
|
+
};
|
|
70
|
+
window.addEventListener('resize', schedule);
|
|
71
|
+
window.addEventListener('orientationchange', schedule);
|
|
72
|
+
const vv = window.visualViewport;
|
|
73
|
+
vv?.addEventListener('resize', schedule);
|
|
74
|
+
return () => {
|
|
75
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
76
|
+
window.removeEventListener('resize', schedule);
|
|
77
|
+
window.removeEventListener('orientationchange', schedule);
|
|
78
|
+
vv?.removeEventListener('resize', schedule);
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/ghost.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual clones used for exit animations (popped screens, dismissed sheets,
|
|
3
|
+
* removed elements with a transition) must stay invisible to the renderer,
|
|
4
|
+
* which looks elements up by `data-sb-id`. Stripping that attribute, however,
|
|
5
|
+
* also strips the stylesheet's positioning rules keyed on it, which made a
|
|
6
|
+
* clone's content collapse to flow layout mid-transition. Clones therefore
|
|
7
|
+
* carry the id under `data-sb-ghost` instead, and the stylesheet applies its
|
|
8
|
+
* element rules to both attributes.
|
|
9
|
+
*/
|
|
10
|
+
export const GHOST_ATTR = 'data-sb-ghost';
|
|
11
|
+
|
|
12
|
+
/** Deep-clone `el` for an exit animation, renaming every element id to a ghost id. */
|
|
13
|
+
export function ghostClone(el: HTMLElement): HTMLElement {
|
|
14
|
+
const clone = el.cloneNode(true) as HTMLElement;
|
|
15
|
+
const rename = (node: Element): void => {
|
|
16
|
+
const id = node.getAttribute('data-sb-id');
|
|
17
|
+
if (id !== null) {
|
|
18
|
+
node.removeAttribute('data-sb-id');
|
|
19
|
+
node.setAttribute(GHOST_ATTR, id);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
rename(clone);
|
|
23
|
+
for (const node of clone.querySelectorAll('[data-sb-id]')) rename(node);
|
|
24
|
+
return clone;
|
|
25
|
+
}
|