@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/README.md +26 -0
- package/bin/swiftbrowser-native.mjs +25 -5
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +15 -4
- package/dist/build.js.map +1 -1
- package/dist/site.d.ts +52 -0
- package/dist/site.d.ts.map +1 -0
- package/dist/site.js +74 -0
- package/dist/site.js.map +1 -0
- package/dist/wasmHost.d.ts +6 -32
- package/dist/wasmHost.d.ts.map +1 -1
- package/dist/wasmHost.js +6 -117
- package/dist/wasmHost.js.map +1 -1
- package/package.json +8 -3
- package/src/build.ts +16 -4
- package/src/site.ts +111 -0
- package/src/wasmHost.ts +6 -145
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +9 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Charts.swift +340 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Inspector.swift +29 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +78 -14
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +58 -6
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +25 -7
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +176 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +2 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +41 -0
- package/wasm/SwiftBrowserNative.wasm +4 -0
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|