@swiftbrowser/native 0.10.4 → 0.12.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/src/site.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The native app as a static site (`swiftbrowser-native site`): the same
3
+ * bundle `build` writes for the Xcode target, run in the browser by the
4
+ * SwiftBrowser Native module built for WebAssembly (docs/native.md, "The
5
+ * round trip"): the native views compiled against the SwiftUI shim, with the
6
+ * page's JavaScript as the engine. The web renderer draws what the shim
7
+ * emits, as it draws any Swift app. The site is what `swiftbrowser build`
8
+ * writes for a Swift app: the page at `/`, the module, the bundle as the
9
+ * app's one resource (`/bundle/app.js`, which the module evaluates at
10
+ * launch) and the manifest. Node only.
11
+ */
12
+
13
+ import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { buildSite } from '@swiftbrowser/web';
18
+ import { bundle, type BuildOptions } from './build.js';
19
+
20
+ /** The module as the package ships it (publish.yml builds it; `wasm/` is gitignored in the repository). */
21
+ export const PACKAGED_MODULE = fileURLToPath(new URL('../wasm/SwiftBrowserNative.wasm', import.meta.url));
22
+
23
+ export interface SiteOptions extends Omit<BuildOptions, 'outfile'> {
24
+ /** The directory to write the site into (emptied first). */
25
+ outDir: string;
26
+ /**
27
+ * The site's app name (letters, digits, `_` and `-`): the module's file
28
+ * name and the document title. The entry's project folder's name by default
29
+ * (`js/src/index.tsx` → the folder holding `js`, as `native init` lays an
30
+ * app out), else `App`.
31
+ */
32
+ name?: string;
33
+ /** Shown as the document title and in the web manifest; `name` by default. */
34
+ displayName?: string;
35
+ /** The SwiftBrowser Native module built for WebAssembly; `PACKAGED_MODULE` by default. */
36
+ module?: string;
37
+ /** The path the site is served under when it is not a host's root (`/native/`); `/` by default. */
38
+ base?: string;
39
+ /**
40
+ * Asset catalogs (`*.xcassets` directories) the app's named images and
41
+ * colors come from, served as the site's catalog; the Xcode target's, or
42
+ * one kept for the site (a `.symbolset` holding any SVG draws as a symbol).
43
+ */
44
+ catalogs?: string[];
45
+ /** Vite's log level while building the site; `warn` by default. */
46
+ logLevel?: 'info' | 'warn' | 'error' | 'silent';
47
+ }
48
+
49
+ export interface SiteResult {
50
+ name: string;
51
+ outDir: string;
52
+ module: string;
53
+ moduleBytes: number;
54
+ bundleBytes: number;
55
+ warnings: string[];
56
+ }
57
+
58
+ const APP_NAME = /^[A-Za-z0-9_-]+$/;
59
+
60
+ /** The app name an entry suggests: the folder holding its `js/` project, else the entry's own folder. */
61
+ export function siteNameFor(entry: string): string {
62
+ const dir = path.dirname(path.resolve(entry));
63
+ const parts = dir.split(path.sep);
64
+ const js = parts.lastIndexOf('js');
65
+ const candidate = js > 0 ? parts[js - 1] : path.basename(dir) === 'src' ? parts[parts.length - 2] : path.basename(dir);
66
+ const cleaned = (candidate ?? '').replace(/[^A-Za-z0-9_-]/g, '');
67
+ return cleaned && APP_NAME.test(cleaned) ? cleaned : 'App';
68
+ }
69
+
70
+ /** Bundles the app and writes the site around the module. */
71
+ export async function buildNativeSite(options: SiteOptions): Promise<SiteResult> {
72
+ const name = options.name ?? siteNameFor(options.entry);
73
+ if (!APP_NAME.test(name)) throw new Error(`site: the app name must be letters, digits, "_" and "-": ${name}`);
74
+ const module = path.resolve(options.module ?? PACKAGED_MODULE);
75
+ if (!existsSync(module)) {
76
+ throw new Error(
77
+ module === PACKAGED_MODULE
78
+ ? `site: this @swiftbrowser/native has no module at ${module}; pass one with --module (swift build --swift-sdk <wasm sdk> -c release --product NativeRoundTrip in a SwiftBrowser checkout)`
79
+ : `site: no module at ${module}`,
80
+ );
81
+ }
82
+ if (options.base !== undefined && !/^\/([^/].*\/)?$/.test(options.base)) throw new Error(`site: --base must start and end with "/": ${options.base}`);
83
+ const { outDir, name: _name, displayName, module: _module, base, logLevel, catalogs, ...bundleOptions } = options;
84
+ for (const catalog of catalogs ?? []) {
85
+ if (!existsSync(catalog)) throw new Error(`site: no asset catalog at ${catalog}`);
86
+ }
87
+ // The bundle is the app's one resource: published at `/bundle/<name>/app.js`
88
+ // and mounted at `/bundle/app.js`, where the module's launch reads it.
89
+ const resources = mkdtempSync(path.join(tmpdir(), 'swiftbrowser-native-site-'));
90
+ try {
91
+ const built = await bundle({ ...bundleOptions, outfile: path.join(resources, 'app.js') });
92
+ writeFileSync(path.join(resources, 'app.js'), built.code);
93
+ await buildSite({
94
+ apps: [{
95
+ name,
96
+ displayName: displayName ?? name,
97
+ description: 'A SwiftBrowser Native app in the browser',
98
+ wasm: module,
99
+ catalogs: (catalogs ?? []).map((catalog) => path.resolve(catalog)),
100
+ resources: [path.join(resources, 'app.js')],
101
+ }],
102
+ layout: 'app',
103
+ outDir: path.resolve(outDir),
104
+ ...(base !== undefined ? { base } : {}),
105
+ logLevel: logLevel ?? 'warn',
106
+ });
107
+ return { name, outDir: path.resolve(outDir), module, moduleBytes: statSync(module).size, bundleBytes: built.bytes, warnings: built.warnings };
108
+ } finally {
109
+ rmSync(resources, { recursive: true, force: true });
110
+ }
111
+ }
package/src/wasmHost.ts CHANGED
@@ -1,147 +1,8 @@
1
1
  /**
2
- * The host's JavaScript as SwiftBrowser Native's engine (docs/native.md,
3
- * "The round trip"): for a module built with the round-trip target
4
- * (`Native/RoundTrip`, the native views compiled against the shim), this
5
- * answers its one import, `sb_native.request`, and exposes the native
6
- * functions Swift installs (`applyOps`, `log`, `now`, …) on `__sbNative`,
7
- * each a call of the module's `sb_native_invoke` export.
8
- *
9
- * Node (scripts/run-wasi.mjs) and a page use it the same way: merge
10
- * `imports` into the module's imports, instantiate, then `attach` the
11
- * instance. Strings cross linear memory as UTF-8; an answer to Swift is a
12
- * buffer from `sb_alloc` holding a little-endian 4-byte length and the
13
- * bytes, which Swift frees, and a result from Swift is the same layout,
14
- * freed with `sb_native_free` once read.
2
+ * `@swiftbrowser/native/wasm-host`: the host's JavaScript as SwiftBrowser
3
+ * Native's engine for a module built with the round-trip target (docs/native.md,
4
+ * "The round trip"). The implementation lives in the web renderer
5
+ * (Web/src/nativeHost.ts), which attaches it to any module that imports
6
+ * `sb_native`; this entry is for Node (scripts/run-wasi.mjs) and tests.
15
7
  */
16
-
17
- export interface WasmNativeHostOptions {
18
- /** Runs a script at the global scope (default: an indirect `eval`). */
19
- evaluate?: (script: string, name: string) => void;
20
- /** The global object the bundle's globals live on (default `globalThis`). */
21
- global?: Record<string, unknown>;
22
- }
23
-
24
- export interface WasmNativeHost {
25
- /** Merge into the module's import object before instantiating. */
26
- imports: {
27
- sb_native: {
28
- request: (kind: number, kindLength: number, a: number, aLength: number, b: number, bLength: number) => number;
29
- };
30
- };
31
- /** The instance, once instantiated: its memory and the exports the host calls. */
32
- attach(instance: WebAssembly.Instance): void;
33
- /** The `__sbNative` object as it stands (replaced by a reset). */
34
- readonly native: Record<string, (...args: unknown[]) => unknown>;
35
- }
36
-
37
- interface Exports {
38
- memory: WebAssembly.Memory;
39
- sb_alloc: (length: number) => number;
40
- sb_native_invoke: (name: number, nameLength: number, args: number, argsLength: number) => number;
41
- sb_native_free: (pointer: number) => void;
42
- }
43
-
44
- /** The globals a bundle defines, dropped by a reset (`JSHost.reset`). */
45
- const BUNDLE_GLOBALS = ['__sbDispatch', '__sbTimerFired', '__sbFetchResult', '__sbRefresh', '__sbVendor'];
46
-
47
- export function wasmNativeHost(options: WasmNativeHostOptions = {}): WasmNativeHost {
48
- const g = options.global ?? (globalThis as unknown as Record<string, unknown>);
49
- const evaluate = options.evaluate ?? ((script: string) => (0, eval)(script));
50
- const encoder = new TextEncoder();
51
- const decoder = new TextDecoder();
52
- let exports: Exports | null = null;
53
- let native: Record<string, (...args: unknown[]) => unknown> = {};
54
-
55
- const need = (): Exports => {
56
- if (!exports) throw new Error('wasmNativeHost: attach(instance) before the module runs');
57
- return exports;
58
- };
59
- const read = (pointer: number, length: number): string =>
60
- decoder.decode(new Uint8Array(need().memory.buffer, pointer, length));
61
- /** A buffer from `sb_alloc` with the text's length and bytes; Swift frees it. */
62
- const answer = (value: unknown): number => {
63
- const bytes = encoder.encode(JSON.stringify(value));
64
- const pointer = need().sb_alloc(bytes.length + 4);
65
- // The allocation may have grown the memory: take the view after it.
66
- const view = new DataView(need().memory.buffer);
67
- view.setUint32(pointer, bytes.length, true);
68
- new Uint8Array(need().memory.buffer, pointer + 4, bytes.length).set(bytes);
69
- return pointer;
70
- };
71
- const describe = (error: unknown): string => (error instanceof Error ? (error.stack ?? error.message) : String(error));
72
-
73
- /** A native function's call: into Swift and back with its JSON result. */
74
- const invoke = (name: string, args: unknown[]): unknown => {
75
- const nameBytes = encoder.encode(name);
76
- const argsBytes = encoder.encode(JSON.stringify(args));
77
- const namePointer = need().sb_alloc(nameBytes.length || 1);
78
- new Uint8Array(need().memory.buffer, namePointer, nameBytes.length).set(nameBytes);
79
- const argsPointer = need().sb_alloc(argsBytes.length || 1);
80
- new Uint8Array(need().memory.buffer, argsPointer, argsBytes.length).set(argsBytes);
81
- const result = need().sb_native_invoke(namePointer, nameBytes.length, argsPointer, argsBytes.length);
82
- if (result === 0) return undefined;
83
- const length = new DataView(need().memory.buffer).getUint32(result, true);
84
- const text = read(result + 4, length);
85
- need().sb_native_free(result);
86
- const value: unknown = JSON.parse(text);
87
- if (value && typeof value === 'object' && 'error' in value && Object.keys(value).length === 1) {
88
- throw new Error(String((value as { error: unknown }).error));
89
- }
90
- return value;
91
- };
92
-
93
- const reset = (): void => {
94
- for (const name of BUNDLE_GLOBALS) delete g[name];
95
- native = {};
96
- g['__sbNative'] = native;
97
- };
98
-
99
- const request = (kind: number, kindLength: number, a: number, aLength: number, b: number, bLength: number): number => {
100
- const what = read(kind, kindLength);
101
- const first = read(a, aLength);
102
- const second = read(b, bLength);
103
- switch (what) {
104
- case 'reset':
105
- reset();
106
- return 0;
107
- case 'install':
108
- native[first] = (...args: unknown[]) => invoke(first, args);
109
- return 0;
110
- case 'eval':
111
- try {
112
- evaluate(first, second);
113
- return 0;
114
- } catch (error) {
115
- return answer({ error: describe(error) });
116
- }
117
- case 'call': {
118
- const fn = g[first];
119
- if (typeof fn !== 'function') return answer({ error: `${first} is not defined` });
120
- try {
121
- const result: unknown = (fn as (...args: unknown[]) => unknown)(...(JSON.parse(second) as unknown[]));
122
- return answer({ ok: result === undefined ? null : result });
123
- } catch (error) {
124
- return answer({ error: describe(error) });
125
- }
126
- }
127
- case 'has':
128
- return answer({ ok: first in g });
129
- default:
130
- return answer({ error: `unknown request ${what}` });
131
- }
132
- };
133
-
134
- reset();
135
- return {
136
- imports: { sb_native: { request } },
137
- attach(instance) {
138
- for (const name of ['memory', 'sb_alloc', 'sb_native_invoke', 'sb_native_free']) {
139
- if (!(name in instance.exports)) throw new Error(`wasmNativeHost: the module does not export ${name}`);
140
- }
141
- exports = instance.exports as unknown as Exports;
142
- },
143
- get native() {
144
- return native;
145
- },
146
- };
147
- }
8
+ export { wasmNativeHost, type WasmNativeHost, type WasmNativeHostOptions } from '@swiftbrowser/web';
@@ -26,7 +26,7 @@ struct ElementView: View {
26
26
  case "zstack": return ZStackElement(node: node).reportingSize(of: node)
27
27
  case "spacer": return AnyView(Spacer(minLength: SpacerProps(props: node.props).minLength.map { CGFloat($0) }))
28
28
  case "divider": return AnyView(Divider())
29
- case "styled": return node.isSpinner ? AnyView(ProgressView()) : ChildrenView(node: node).styled(node.style)
29
+ case "styled": return node.isSpinner ? AnyView(ProgressView()) : ChildrenView(node: node).styled(node.style, id: node.id)
30
30
  case "decorated": return AnyView(DecoratedElement(node: node))
31
31
  case "geometry": return AnyView(GeometryElement(node: node))
32
32
  case "scrollview": return AnyView(ScrollViewElement(node: node))
@@ -57,6 +57,14 @@ struct ElementView: View {
57
57
  case "swipeactions": return AnyView(SwipeActionsElement(node: node))
58
58
  // Navigation and presentation
59
59
  case "navstack": return AnyView(NavigationStackElement(node: node))
60
+ case "navsplit": return AnyView(NavigationSplitElement(node: node))
61
+ case "inspector": return AnyView(InspectorElement(node: node))
62
+ case "inspectorpanel": return AnyView(ChildrenView(node: node))
63
+ // Charts
64
+ case "chart": return AnyView(ChartElement(node: node))
65
+ case "chartitem":
66
+ // Placed by the chart; in place it draws nothing.
67
+ return AnyView(EmptyView())
60
68
  case "navscreen": return AnyView(NavigationScreenElement(node: node))
61
69
  case "navlink": return AnyView(NavigationLinkElement(node: node))
62
70
  case "tabview": return AnyView(TabViewElement(node: node))
@@ -0,0 +1,340 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `chart`: Swift Charts as the protocol carries it, already resolved (the
8
+ /// marks in unit coordinates of the plot, the axes' grid lines, ticks and
9
+ /// formatted labels, the legend), drawn with paths and texts, following the
10
+ /// web renderer's layout rules (docs/ops-protocol.md, "Layout rules: chart"):
11
+ /// the legend row, the axis bands as wide as their labels, and the plot in
12
+ /// what remains. `chartitem` children are annotations against their mark's
13
+ /// rect, or axis label views at their tick.
14
+ struct ChartElement: View {
15
+ let node: ElementNode
16
+
17
+ var body: some View {
18
+ let props = ChartProps(props: node.props)
19
+ let items = node.children.filter { $0.kind == "chartitem" }
20
+ GeometryReader { geometry in
21
+ ChartCanvas(props: props, items: items, size: geometry.size)
22
+ }
23
+ }
24
+ }
25
+
26
+ /// The chart's boxes in points.
27
+ struct ChartLayout {
28
+ var plot: CGRect
29
+ var xBand: CGRect?
30
+ var yBand: CGRect?
31
+ var legend: CGRect?
32
+
33
+ static let axisFontSize: CGFloat = 12
34
+ /// A caption glyph's average width, for the y band's width (the labels are not measured).
35
+ static let glyphWidth: CGFloat = 6.6
36
+ static let labelGap: CGFloat = 6
37
+ static let legendHeight: CGFloat = 16
38
+ static let legendGap: CGFloat = 8
39
+
40
+ init(props: ChartProps, size: CGSize) {
41
+ var plot = CGRect(origin: .zero, size: size)
42
+ if let legend = props.legend, !legend.items.isEmpty {
43
+ switch legend.position {
44
+ case "top":
45
+ self.legend = CGRect(x: 0, y: 0, width: size.width, height: Self.legendHeight)
46
+ plot.origin.y += Self.legendHeight + Self.legendGap
47
+ plot.size.height -= Self.legendHeight + Self.legendGap
48
+ case "leading", "trailing":
49
+ let width = legend.items.map { CGFloat($0.label.count) * Self.glyphWidth + 14 }.max() ?? 0
50
+ if legend.position == "leading" {
51
+ self.legend = CGRect(x: 0, y: 0, width: width, height: size.height)
52
+ plot.origin.x += width + Self.legendGap
53
+ } else {
54
+ self.legend = CGRect(x: size.width - width, y: 0, width: width, height: size.height)
55
+ }
56
+ plot.size.width -= width + Self.legendGap
57
+ default:
58
+ self.legend = CGRect(x: 0, y: size.height - Self.legendHeight, width: size.width, height: Self.legendHeight)
59
+ plot.size.height -= Self.legendHeight + Self.legendGap
60
+ }
61
+ }
62
+ if let axis = props.yAxis, !axis.labels.isEmpty {
63
+ let width = (axis.labels.map { CGFloat($0.text.count) }.max() ?? 0) * Self.glyphWidth + Self.labelGap
64
+ if axis.position == "leading" {
65
+ yBand = CGRect(x: plot.minX, y: plot.minY, width: width, height: plot.height)
66
+ plot.origin.x += width
67
+ } else {
68
+ yBand = CGRect(x: plot.maxX - width, y: plot.minY, width: width, height: plot.height)
69
+ }
70
+ plot.size.width -= width
71
+ }
72
+ if let axis = props.xAxis, !axis.labels.isEmpty {
73
+ let height = Self.axisFontSize * 4 / 3 + Self.labelGap
74
+ if axis.position == "top" {
75
+ xBand = CGRect(x: plot.minX, y: plot.minY, width: plot.width, height: height)
76
+ plot.origin.y += height
77
+ } else {
78
+ xBand = CGRect(x: plot.minX, y: plot.maxY - height, width: plot.width, height: height)
79
+ }
80
+ plot.size.height -= height
81
+ }
82
+ self.plot = CGRect(x: plot.minX, y: plot.minY, width: max(plot.width, 0), height: max(plot.height, 0))
83
+ }
84
+
85
+ func point(x: Double, y: Double) -> CGPoint {
86
+ CGPoint(x: plot.minX + plot.width * x, y: plot.minY + plot.height * y)
87
+ }
88
+
89
+ func rect(x0: Double, x1: Double, y0: Double, y1: Double) -> CGRect {
90
+ let a = point(x: min(x0, x1), y: min(y0, y1)), b = point(x: max(x0, x1), y: max(y0, y1))
91
+ return CGRect(x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y)
92
+ }
93
+ }
94
+
95
+ struct ChartCanvas: View {
96
+ let props: ChartProps
97
+ let items: [ElementNode]
98
+ let size: CGSize
99
+
100
+ var body: some View {
101
+ let layout = ChartLayout(props: props, size: size)
102
+ ZStack(alignment: .topLeading) {
103
+ axisLines(props.xAxis, layout: layout, horizontal: true)
104
+ axisLines(props.yAxis, layout: layout, horizontal: false)
105
+ ForEach(Array(props.marks.enumerated()), id: \.offset) { _, mark in
106
+ ChartMarkView(mark: mark, layout: layout)
107
+ }
108
+ axisLabels(props.xAxis, layout: layout, horizontal: true)
109
+ axisLabels(props.yAxis, layout: layout, horizontal: false)
110
+ if let legend = props.legend, let box = layout.legend {
111
+ ChartLegendView(legend: legend)
112
+ .frame(width: box.width, height: box.height)
113
+ .position(x: box.midX, y: box.midY)
114
+ }
115
+ ForEach(items) { item in
116
+ ChartItemView(item: item, layout: layout)
117
+ }
118
+ }
119
+ .frame(width: size.width, height: size.height, alignment: .topLeading)
120
+ .clipped()
121
+ }
122
+
123
+ /// The grid lines across the plot and the ticks outside it.
124
+ @ViewBuilder
125
+ private func axisLines(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
126
+ if let axis {
127
+ let plot = layout.plot
128
+ let grid = Path { path in
129
+ for line in axis.gridLines {
130
+ if horizontal {
131
+ let x = plot.minX + plot.width * line
132
+ path.move(to: CGPoint(x: x, y: plot.minY))
133
+ path.addLine(to: CGPoint(x: x, y: plot.maxY))
134
+ } else {
135
+ let y = plot.minY + plot.height * line
136
+ path.move(to: CGPoint(x: plot.minX, y: y))
137
+ path.addLine(to: CGPoint(x: plot.maxX, y: y))
138
+ }
139
+ }
140
+ }
141
+ let ticks = Path { path in
142
+ for tick in axis.ticks {
143
+ if horizontal {
144
+ let x = plot.minX + plot.width * tick
145
+ let y = axis.position == "top" ? plot.minY : plot.maxY
146
+ path.move(to: CGPoint(x: x, y: y))
147
+ path.addLine(to: CGPoint(x: x, y: axis.position == "top" ? y - 4 : y + 4))
148
+ } else {
149
+ let y = plot.minY + plot.height * tick
150
+ let x = axis.position == "leading" ? plot.minX : plot.maxX
151
+ path.move(to: CGPoint(x: x, y: y))
152
+ path.addLine(to: CGPoint(x: axis.position == "leading" ? x - 4 : x + 4, y: y))
153
+ }
154
+ }
155
+ }
156
+ let separator = Color.secondary.opacity(0.3)
157
+ grid.stroke(separator, style: StrokeStyle(lineWidth: 1, dash: axis.dashed ? [2, 2] : []))
158
+ ticks.stroke(separator, lineWidth: 1)
159
+ }
160
+ }
161
+
162
+ /// The default text labels, centered on their ticks in the band.
163
+ @ViewBuilder
164
+ private func axisLabels(_ axis: ChartAxisSpec?, layout: ChartLayout, horizontal: Bool) -> some View {
165
+ if let axis {
166
+ ForEach(Array(axis.labels.enumerated()), id: \.offset) { _, label in
167
+ let text = Text(verbatim: label.text)
168
+ .font(.system(size: ChartLayout.axisFontSize))
169
+ .foregroundStyle(.secondary)
170
+ .lineLimit(1)
171
+ .fixedSize()
172
+ if horizontal, let band = layout.xBand {
173
+ let x = layout.plot.minX + layout.plot.width * label.at
174
+ if label.anchor == "leading" {
175
+ text.frame(width: max(band.width, 1), alignment: .leading).position(x: x + band.width / 2, y: band.midY)
176
+ } else {
177
+ text.position(x: min(max(x, layout.plot.minX), layout.plot.maxX), y: band.midY)
178
+ }
179
+ } else if !horizontal, let band = layout.yBand {
180
+ let y = layout.plot.minY + layout.plot.height * label.at
181
+ text.frame(width: max(band.width - ChartLayout.labelGap, 1), alignment: axis.position == "leading" ? .trailing : .leading)
182
+ .position(x: band.midX + (axis.position == "leading" ? -ChartLayout.labelGap / 2 : ChartLayout.labelGap / 2), y: min(max(y, layout.plot.minY), layout.plot.maxY))
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /// One mark, in the plot's points.
190
+ struct ChartMarkView: View {
191
+ let mark: ChartMarkSpec
192
+ let layout: ChartLayout
193
+
194
+ var body: some View {
195
+ let style = mark.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
196
+ Group {
197
+ switch mark.kind {
198
+ case "rect":
199
+ let unit = layout.rect(x0: mark.x0, x1: mark.x1, y0: mark.y0, y1: mark.y1)
200
+ let width = mark.fixedWidth.map { CGFloat($0) } ?? unit.width
201
+ RoundedRectangle(cornerRadius: mark.cornerRadius, style: .continuous)
202
+ .fill(style)
203
+ .frame(width: max(width, 0), height: max(unit.height, 0))
204
+ .position(x: unit.midX, y: unit.midY)
205
+ case "line":
206
+ let points = mark.points.map { layout.point(x: $0.x, y: $0.y) }
207
+ Path { path in
208
+ if let first = points.first {
209
+ path.move(to: first)
210
+ path.addLines(Array(points.dropFirst()))
211
+ }
212
+ }
213
+ .stroke(style, style: StrokeStyle(lineWidth: mark.lineWidth, lineCap: .round, lineJoin: .round))
214
+ if let symbol = mark.symbol {
215
+ ForEach(Array(points.enumerated()), id: \.offset) { _, point in
216
+ ChartSymbolView(symbol: symbol, size: mark.symbolSize, style: style).position(point)
217
+ }
218
+ }
219
+ case "area":
220
+ let upper = mark.points.map { layout.point(x: $0.x, y: $0.y0) }
221
+ let lower = mark.points.reversed().map { layout.point(x: $0.x, y: $0.y1) }
222
+ Path { path in
223
+ if let first = upper.first {
224
+ path.move(to: first)
225
+ path.addLines(Array(upper.dropFirst()))
226
+ path.addLines(lower)
227
+ path.closeSubpath()
228
+ }
229
+ }
230
+ .fill(style)
231
+ case "point":
232
+ ChartSymbolView(symbol: mark.symbol ?? "circle", size: mark.symbolSize, style: style)
233
+ .position(layout.point(x: mark.x0, y: mark.y0))
234
+ case "rule":
235
+ Path { path in
236
+ path.move(to: layout.point(x: mark.x0, y: mark.y0))
237
+ path.addLine(to: layout.point(x: mark.x1, y: mark.y1))
238
+ }
239
+ .stroke(style, lineWidth: mark.lineWidth)
240
+ default:
241
+ EmptyView()
242
+ }
243
+ }
244
+ .opacity(mark.opacity)
245
+ }
246
+ }
247
+
248
+ /// A mark's symbol glyph: a circle, a square, or a diamond for the rest.
249
+ struct ChartSymbolView: View {
250
+ let symbol: String
251
+ let size: Double
252
+ let style: AnyShapeStyle
253
+
254
+ var body: some View {
255
+ switch symbol {
256
+ case "square":
257
+ Rectangle().fill(style).frame(width: size, height: size)
258
+ case "circle":
259
+ Circle().fill(style).frame(width: size, height: size)
260
+ default:
261
+ Rectangle().fill(style).frame(width: size * 0.8, height: size * 0.8).rotationEffect(.degrees(45))
262
+ }
263
+ }
264
+ }
265
+
266
+ /// The legend's items: a symbol (or a short line) and the label, flowing in a row.
267
+ struct ChartLegendView: View {
268
+ let legend: ChartLegendSpec
269
+
270
+ var body: some View {
271
+ let items = Array(legend.items.enumerated())
272
+ if legend.position == "leading" || legend.position == "trailing" {
273
+ VStack(alignment: .leading, spacing: 4) {
274
+ ForEach(items, id: \.offset) { _, item in row(item) }
275
+ }
276
+ } else {
277
+ HStack(spacing: 12) {
278
+ ForEach(items, id: \.offset) { _, item in row(item) }
279
+ }
280
+ }
281
+ }
282
+
283
+ private func row(_ item: ChartLegendItemSpec) -> some View {
284
+ let style = item.style.map(AnyShapeStyle.init(spec:)) ?? AnyShapeStyle(Color.accentColor)
285
+ return HStack(spacing: 4) {
286
+ if let symbol = item.symbol {
287
+ ChartSymbolView(symbol: symbol, size: 10, style: style)
288
+ } else {
289
+ Rectangle().fill(style).frame(width: 18, height: 3)
290
+ }
291
+ Text(verbatim: item.label).font(.system(size: ChartLayout.axisFontSize)).foregroundStyle(.secondary).lineLimit(1)
292
+ }
293
+ }
294
+ }
295
+
296
+ /// A `chartitem`: its one child against its mark's rect (an annotation) or
297
+ /// at its tick in the band (an axis label view). An annotation beside the
298
+ /// rect sits in a frame the rect's size, aligned to the edge it leaves from,
299
+ /// so it overflows outward by its own size plus the spacing.
300
+ struct ChartItemView: View {
301
+ let item: ElementNode
302
+ let layout: ChartLayout
303
+
304
+ var body: some View {
305
+ let props = ChartItemProps(props: item.props)
306
+ let content = ZStack { ChildrenView(node: item) }
307
+ switch props.role {
308
+ case "xLabel":
309
+ let band = layout.xBand ?? CGRect(x: layout.plot.minX, y: layout.plot.maxY, width: layout.plot.width, height: 0)
310
+ content.position(x: layout.plot.minX + layout.plot.width * props.x0, y: band.midY)
311
+ case "yLabel":
312
+ let band = layout.yBand ?? CGRect(x: layout.plot.maxX, y: layout.plot.minY, width: 0, height: layout.plot.height)
313
+ content.position(x: band.midX, y: layout.plot.minY + layout.plot.height * props.y0)
314
+ default:
315
+ let rect = layout.rect(x0: props.x0, x1: props.x1, y0: props.y0, y1: props.y1)
316
+ let spacing = CGFloat(props.spacing)
317
+ let across = Alignment(name: props.alignment)
318
+ Group {
319
+ switch props.position {
320
+ case "top":
321
+ VStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
322
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .bottom))
323
+ case "bottom":
324
+ VStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
325
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: across.horizontal, vertical: .top))
326
+ case "leading":
327
+ HStack(spacing: spacing) { content; Color.clear.frame(width: rect.width, height: rect.height) }
328
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .trailing, vertical: across.vertical))
329
+ case "trailing":
330
+ HStack(spacing: spacing) { Color.clear.frame(width: rect.width, height: rect.height); content }
331
+ .frame(width: rect.width, height: rect.height, alignment: Alignment(horizontal: .leading, vertical: across.vertical))
332
+ default:
333
+ content.frame(width: rect.width, height: rect.height, alignment: across)
334
+ }
335
+ }
336
+ .position(x: rect.midX, y: rect.midY)
337
+ }
338
+ }
339
+ }
340
+ #endif
@@ -0,0 +1,29 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// `inspector`: `.inspector(isPresented:)` in a regular width. The children
8
+ /// are the modified view's elements and, last, an `inspectorpanel` holding
9
+ /// the inspector's content (its `inspectorColumnWidth` hint rides the
10
+ /// panel content's style). The app owns the binding: `presented` is what it
11
+ /// holds, and the protocol has no dismiss from this side (in a compact width
12
+ /// the app presents a sheet instead and the element never appears).
13
+ struct InspectorElement: View {
14
+ let node: ElementNode
15
+
16
+ var body: some View {
17
+ let props = InspectorProps(props: node.props)
18
+ let panel = node.children.last { $0.kind == "inspectorpanel" }
19
+ ZStack {
20
+ ForEach(node.children.filter { $0.kind != "inspectorpanel" }) { ElementView(node: $0) }
21
+ }
22
+ .inspector(isPresented: Binding(get: { props.presented }, set: { _ in })) {
23
+ if let panel {
24
+ ZStack { ChildrenView(node: panel) }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ #endif