@motionscript/geography 0.1.0-alpha.7 → 0.1.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +104 -1
  2. package/CREDITS.md +8 -8
  3. package/LICENSE +201 -201
  4. package/NOTICE +49 -48
  5. package/README.md +149 -85
  6. package/dist/border/format.d.ts +73 -0
  7. package/dist/border/format.d.ts.map +1 -0
  8. package/dist/border/format.js +68 -0
  9. package/dist/border/format.js.map +1 -0
  10. package/dist/border/geo-border.d.ts +103 -0
  11. package/dist/border/geo-border.d.ts.map +1 -0
  12. package/dist/border/geo-border.js +203 -0
  13. package/dist/border/geo-border.js.map +1 -0
  14. package/dist/border/geometry.d.ts +40 -0
  15. package/dist/border/geometry.d.ts.map +1 -0
  16. package/dist/border/geometry.js +382 -0
  17. package/dist/border/geometry.js.map +1 -0
  18. package/dist/border/index.d.ts +15 -0
  19. package/dist/border/index.d.ts.map +1 -0
  20. package/dist/border/index.js +15 -0
  21. package/dist/border/index.js.map +1 -0
  22. package/dist/border/loader.d.ts +31 -0
  23. package/dist/border/loader.d.ts.map +1 -0
  24. package/dist/border/loader.js +87 -0
  25. package/dist/border/loader.js.map +1 -0
  26. package/dist/border/registry.d.ts +64 -0
  27. package/dist/border/registry.d.ts.map +1 -0
  28. package/dist/border/registry.js +163 -0
  29. package/dist/border/registry.js.map +1 -0
  30. package/dist/border/selection.d.ts +21 -0
  31. package/dist/border/selection.d.ts.map +1 -0
  32. package/dist/border/selection.js +86 -0
  33. package/dist/border/selection.js.map +1 -0
  34. package/dist/border/simplify.d.ts +12 -0
  35. package/dist/border/simplify.d.ts.map +1 -0
  36. package/dist/border/simplify.js +116 -0
  37. package/dist/border/simplify.js.map +1 -0
  38. package/dist/browser/THIRD_PARTY_LICENSES.txt +58 -0
  39. package/dist/browser/index.js +2 -0
  40. package/dist/browser/index.js.map +7 -0
  41. package/dist/browser/manifest.json +23 -0
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +3 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/nodes.d.ts +15 -0
  47. package/dist/nodes.d.ts.map +1 -0
  48. package/dist/nodes.js +16 -0
  49. package/dist/nodes.js.map +1 -0
  50. package/package.json +46 -8
  51. package/registry.json +16 -0
  52. package/src/border/format.ts +147 -0
  53. package/src/border/geo-border.ts +268 -0
  54. package/src/border/geometry.ts +463 -0
  55. package/src/border/index.ts +14 -0
  56. package/src/border/loader.ts +97 -0
  57. package/src/border/registry.ts +201 -0
  58. package/src/border/selection.ts +95 -0
  59. package/src/border/simplify.ts +110 -0
  60. package/src/index.ts +2 -0
  61. package/src/nodes.ts +16 -0
@@ -0,0 +1,15 @@
1
+ import { GeoBorder } from "./border/index.js";
2
+ /**
3
+ * Every node type this package publishes.
4
+ *
5
+ * A host registers these by handing the array to an engine —
6
+ * `new Engine(platform, { nodes: NODES })` — which reads each component's key.
7
+ * Defining a component only *declares* that key: nothing is registered by
8
+ * importing a module, so a registry holds exactly what its owner asked for.
9
+ *
10
+ * Listing the component **values** is also what survives bundling: this package
11
+ * declares `sideEffects: false`, and a document names a node type by string, so
12
+ * an import kept only for its side effect is exactly what gets shaken out.
13
+ */
14
+ export declare const NODES: (typeof GeoBorder)[];
15
+ //# sourceMappingURL=nodes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nodes.d.ts","sourceRoot":"","sources":["../src/nodes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAErC;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,KAAK,EAAE,CAAC,OAAO,SAAS,CAAC,EAAgB,CAAC"}
package/dist/nodes.js ADDED
@@ -0,0 +1,16 @@
1
+ import { GeoBorder } from "./border/index.js";
2
+ /**
3
+ * Every node type this package publishes.
4
+ *
5
+ * A host registers these by handing the array to an engine —
6
+ * `new Engine(platform, { nodes: NODES })` — which reads each component's key.
7
+ * Defining a component only *declares* that key: nothing is registered by
8
+ * importing a module, so a registry holds exactly what its owner asked for.
9
+ *
10
+ * Listing the component **values** is also what survives bundling: this package
11
+ * declares `sideEffects: false`, and a document names a node type by string, so
12
+ * an import kept only for its side effect is exactly what gets shaken out.
13
+ */
14
+ // Typed by name, so the declaration names each component rather than spelling out its type through core.
15
+ export const NODES = [GeoBorder];
16
+ //# sourceMappingURL=nodes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nodes.js","sourceRoot":"","sources":["../src/nodes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,yGAAyG;AACzG,MAAM,CAAC,MAAM,KAAK,GAAyB,CAAC,SAAS,CAAC,CAAC","sourcesContent":["import { GeoBorder } from \"./border\";\n\n/**\n * Every node type this package publishes.\n *\n * A host registers these by handing the array to an engine —\n * `new Engine(platform, { nodes: NODES })` — which reads each component's key.\n * Defining a component only *declares* that key: nothing is registered by\n * importing a module, so a registry holds exactly what its owner asked for.\n *\n * Listing the component **values** is also what survives bundling: this package\n * declares `sideEffects: false`, and a document names a node type by string, so\n * an import kept only for its side effect is exactly what gets shaken out.\n */\n// Typed by name, so the declaration names each component rather than spelling out its type through core.\nexport const NODES: (typeof GeoBorder)[] = [GeoBorder];\n"]}
package/package.json CHANGED
@@ -1,48 +1,86 @@
1
1
  {
2
2
  "name": "@motionscript/geography",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "type": "module",
5
- "description": "Country and first-level subdivision outlines for Motion Script's GeoBorder, keyed by ISO 3166 code. Derived from Natural Earth (public domain).",
5
+ "description": "Geography for Motion Script: GeoBorder, and the country and state outlines it draws by ISO 3166 code, derived from Natural Earth (public domain).",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/motionscript-dev/motionscript.git",
9
- "directory": "packages/geography"
9
+ "directory": "packages/components/geography"
10
10
  },
11
11
  "homepage": "https://motionscript.dev",
12
12
  "bugs": {
13
13
  "url": "https://github.com/motionscript-dev/motionscript/issues"
14
14
  },
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
15
17
  "sideEffects": false,
16
18
  "files": [
17
19
  "NOTICE",
20
+ "dist",
21
+ "src",
22
+ "registry.json",
23
+ "CHANGELOG.md",
18
24
  "data",
19
25
  "CREDITS.md",
20
- "CHANGELOG.md"
26
+ "!dist/**/*.tsbuildinfo",
27
+ "!src/**/tests/**",
28
+ "!src/**/*.test.ts",
29
+ "!src/**/*.test.tsx",
30
+ "!src/**/*.fixtures.ts"
21
31
  ],
22
32
  "publishConfig": {
23
33
  "access": "public"
24
34
  },
25
35
  "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ },
26
40
  "./package.json": "./package.json",
27
41
  "./data/*": "./data/*"
28
42
  },
29
43
  "keywords": [
30
44
  "motion-script",
45
+ "motion script",
31
46
  "geo",
47
+ "animation",
32
48
  "iso-3166",
33
49
  "natural-earth"
34
50
  ],
35
51
  "license": "Apache-2.0",
52
+ "author": "Motionscript",
53
+ "peerDependencies": {
54
+ "@motionscript/sdk": "^0.1.0-alpha.9"
55
+ },
36
56
  "devDependencies": {
37
- "d3-geo": "3.1.1",
57
+ "@types/d3-geo": "^3.1.0",
58
+ "@types/node": "^25.6.2",
38
59
  "d3-geo-projection": "4.0.0",
60
+ "eslint": "^9.39.4",
61
+ "eslint-import-resolver-typescript": "^4.4.4",
62
+ "eslint-plugin-import-x": "^4.16.1",
39
63
  "topojson-client": "3.1.0",
40
64
  "topojson-server": "3.0.1",
41
- "topojson-simplify": "3.0.3"
65
+ "topojson-simplify": "3.0.3",
66
+ "tsc-alias": "^1.8.16",
67
+ "typescript": "^6.0.3",
68
+ "typescript-eslint": "^8.48.0",
69
+ "vitest": "^4.1.5",
70
+ "@motionscript/core": "0.1.0-alpha.9",
71
+ "@motionscript/sdk": "0.1.0-alpha.9"
72
+ },
73
+ "dependencies": {
74
+ "d3-geo": "^3.1.1"
42
75
  },
43
76
  "scripts": {
77
+ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && node ../../../scripts/build-browser.mjs",
78
+ "dev": "tsc -p tsconfig.build.json --watch & tsc-alias -p tsconfig.build.json --resolve-full-paths -w",
79
+ "clean": "rimraf --glob dist .turbo *.tsbuildinfo",
80
+ "test": "vitest run && node --test \"scripts/**/*.test.mjs\"",
81
+ "lint": "eslint .",
82
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
83
  "generate": "node --max-old-space-size=8192 scripts/generate.mjs",
45
- "verify": "node scripts/verify.mjs",
46
- "test": "node --test \"scripts/**/*.test.mjs\""
84
+ "verify": "node scripts/verify.mjs"
47
85
  }
48
86
  }
package/registry.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "kit": {
3
+ "barrel": "src/index.ts",
4
+ "specifier": "@motionscript/geography"
5
+ },
6
+ "items": [
7
+ {
8
+ "name": "geo-border",
9
+ "description": "Country and state outlines by ISO 3166 code, with adjustable detail.",
10
+ "root": "src/border",
11
+ "files": [
12
+ "geo-border.ts"
13
+ ]
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,147 @@
1
+ import { geoArea } from "d3-geo"
2
+
3
+ /** The on-disk shape `@motionscript/geography` writes; see its `generate.mjs`. */
4
+ export const GEO_DATA_FORMAT = 1
5
+
6
+ export interface GeoFileData {
7
+ format: number
8
+ source?: string
9
+ transform: { scale: [number, number]; translate: [number, number] }
10
+ /** Global arc id per local arc — identical across files for a shared border. */
11
+ arcIds: number[]
12
+ /** Per arc, flat `[x0, y0, w0, dx, dy, w, …]`: delta-encoded quantized lon/lat and a weight bucket. */
13
+ arcs: number[][]
14
+ features: GeoFeatureData[]
15
+ }
16
+
17
+ export interface GeoFeatureData {
18
+ code: string
19
+ name: string
20
+ nameNl?: string
21
+ parent: string | null
22
+ first: boolean
23
+ listed: boolean
24
+ remote?: boolean
25
+ area: number
26
+ mainland: number[] | null
27
+ areas: number[]
28
+ /** Polygons → rings → arc refs (`~i` is arc `i` reversed). */
29
+ polygons: number[][][]
30
+ }
31
+
32
+ /** `[code, name, nameNl, parent, first]` per listed region. */
33
+ export type GeoIndexEntry = [string, string, string, string | null, 0 | 1]
34
+
35
+ export interface GeoIndex {
36
+ format: number
37
+ source?: string
38
+ regions: GeoIndexEntry[]
39
+ }
40
+
41
+ export interface GeoArc {
42
+ /** Identity for de-duplicating a border drawn by two regions, across files. */
43
+ key: number | string
44
+ /** Interleaved lon/lat, in degrees. */
45
+ points: Float64Array
46
+ /** Per point: 0 always survives, higher buckets are less significant; see `thresholdBucket`. */
47
+ buckets: Uint8Array
48
+ }
49
+
50
+ export interface GeoRingRef {
51
+ arc: GeoArc
52
+ reversed: boolean
53
+ }
54
+
55
+ export interface GeoPolygon {
56
+ rings: GeoRingRef[][]
57
+ /** Per ring, in steradians — exterior first, then holes. */
58
+ ringAreas: number[]
59
+ area: number
60
+ mainland: boolean
61
+ }
62
+
63
+ export interface GeoFeature {
64
+ code: string
65
+ name: string
66
+ nameNl: string
67
+ parent: string | null
68
+ first: boolean
69
+ listed: boolean
70
+ remote: boolean
71
+ polygons: GeoPolygon[]
72
+ }
73
+
74
+ export interface DecodedGeoFile {
75
+ features: Map<string, GeoFeature>
76
+ }
77
+
78
+ function decodeArc(encoded: number[], key: number, [kx, ky]: [number, number], [tx, ty]: [number, number]): GeoArc {
79
+ const count = encoded.length / 3
80
+ const points = new Float64Array(count * 2)
81
+ const buckets = new Uint8Array(count)
82
+ let x = 0
83
+ let y = 0
84
+ for (let i = 0; i < count; i++) {
85
+ x += encoded[i * 3]
86
+ y += encoded[i * 3 + 1]
87
+ points[i * 2] = x * kx + tx
88
+ points[i * 2 + 1] = y * ky + ty
89
+ buckets[i] = encoded[i * 3 + 2]
90
+ }
91
+ return { key, points, buckets }
92
+ }
93
+
94
+ export function ringCoordinates(ring: readonly GeoRingRef[]): [number, number][] {
95
+ const out: [number, number][] = []
96
+ for (const { arc, reversed } of ring) {
97
+ const n = arc.buckets.length
98
+ for (let j = out.length === 0 ? 0 : 1; j < n; j++) {
99
+ const i = reversed ? n - 1 - j : j
100
+ out.push([arc.points[i * 2], arc.points[i * 2 + 1]])
101
+ }
102
+ }
103
+ return out
104
+ }
105
+
106
+ /** A ring's area in steradians, whichever way it winds. */
107
+ export function ringArea(ring: readonly GeoRingRef[]): number {
108
+ const coordinates = ringCoordinates(ring)
109
+ if (coordinates.length < 4) return 0
110
+ const area = geoArea({ type: "Polygon", coordinates: [coordinates] })
111
+ return area > 2 * Math.PI ? 4 * Math.PI - area : area
112
+ }
113
+
114
+ export function decodeGeoFile(data: GeoFileData): DecodedGeoFile {
115
+ if (data.format !== GEO_DATA_FORMAT) {
116
+ throw new Error(`geo data format ${data.format} is not supported (expected ${GEO_DATA_FORMAT})`)
117
+ }
118
+ const arcs = data.arcs.map((encoded, i) =>
119
+ decodeArc(encoded, data.arcIds[i], data.transform.scale, data.transform.translate)
120
+ )
121
+ const ref = (index: number): GeoRingRef =>
122
+ index < 0 ? { arc: arcs[~index], reversed: true } : { arc: arcs[index], reversed: false }
123
+
124
+ const features = new Map<string, GeoFeature>()
125
+ for (const f of data.features) {
126
+ const mainland = f.mainland ? new Set(f.mainland) : null
127
+ features.set(f.code, {
128
+ code: f.code,
129
+ name: f.name,
130
+ nameNl: f.nameNl ?? f.name,
131
+ parent: f.parent,
132
+ first: f.first,
133
+ listed: f.listed,
134
+ remote: f.remote === true,
135
+ polygons: f.polygons.map((rings, i) => {
136
+ const resolved = rings.map((ring) => ring.map(ref))
137
+ return {
138
+ rings: resolved,
139
+ ringAreas: resolved.map((ring, r) => (r === 0 ? f.areas[i] : ringArea(ring))),
140
+ area: f.areas[i],
141
+ mainland: mainland === null || mainland.has(i),
142
+ }
143
+ }),
144
+ })
145
+ }
146
+ return { features }
147
+ }
@@ -0,0 +1,268 @@
1
+ import {
2
+ Flow,
3
+ Graphics2D,
4
+ Paint,
5
+ Shapes,
6
+ Types,
7
+ component2d,
8
+ type EntryOptions,
9
+ type FillResolved,
10
+ type NodeOf,
11
+ // Named so this component's emitted type reaches the shape surface it spreads through the sdk, not core.
12
+ type Alignment as _Alignment,
13
+ type Fill as _Fill,
14
+ type FlowMode as _FlowMode,
15
+ type FlowResolved as _FlowResolved,
16
+ type GapSize as _GapSize,
17
+ type Node2D as _Node2D,
18
+ type Node2DProps as _Node2DProps,
19
+ type Shadow as _Shadow,
20
+ type Stroke as _Stroke,
21
+ type Vector2 as _Vector2,
22
+ type PathBounds,
23
+ type ShadowResolved,
24
+ type ShapeProps,
25
+ type Size2D,
26
+ type StrokeResolved,
27
+ type Type,
28
+ } from "@motionscript/sdk"
29
+ import { snapValue } from "@motionscript/sdk/component"
30
+
31
+ import { geoGeometry, type GeoBorders, type GeoContour, type GeoGeometry, type GeoProjectionKind } from "./geometry"
32
+ import { geoDataVersion, geoLoader } from "./loader"
33
+ import { geoRegistry } from "./registry"
34
+ import { geoFilesFor, normalizeIso, selectGeoFeatures, type GeoFrame, type GeoLevel } from "./selection"
35
+
36
+ export interface GeoBorderProps extends ShapeProps {
37
+ /** ISO 3166-1 alpha-2 (`"FR"`) or ISO 3166-2 (`"US-CA"`) codes. */
38
+ iso: string | readonly string[]
39
+ level: GeoLevel
40
+ /** 0–1: 1 is full resolution, lower is a more stylised outline. */
41
+ detail: number
42
+ projection: GeoProjectionKind
43
+ frame: GeoFrame
44
+ borders: GeoBorders
45
+ }
46
+
47
+ interface Chunk {
48
+ data: string
49
+ /** Where this chunk's contours sit along the whole drawing, as fractions of its length. */
50
+ from: number
51
+ to: number
52
+ }
53
+
54
+ interface Drawn {
55
+ geometry: GeoGeometry
56
+ width: number
57
+ height: number
58
+ fills: Chunk[]
59
+ lines: Chunk[]
60
+ }
61
+
62
+ // A renderer's cost for one path grows faster than its contour count, and a shape
63
+ // drawn as several paths under one paint is unioned every frame — so a region
64
+ // is cut into paths of bounded size, each painted on its own.
65
+ const CONTOURS_PER_PATH = 256
66
+ const POINTS_PER_PATH = 40_000
67
+
68
+ const coordinate = (value: number) => String(Math.round(value * 100) / 100)
69
+
70
+ function chunk(contours: readonly GeoContour[], total: number, scale: number): Chunk[] {
71
+ const chunks: Chunk[] = []
72
+ let parts: string[] = []
73
+ let count = 0
74
+ let points = 0
75
+ let from = 0
76
+ let length = 0
77
+ const flush = () => {
78
+ if (count === 0) return
79
+ const to = total > 0 ? Math.min(1, length / total) : 1
80
+ chunks.push({ data: parts.join(""), from, to })
81
+ from = to
82
+ parts = []
83
+ count = 0
84
+ points = 0
85
+ }
86
+ for (const contour of contours) {
87
+ const p = contour.points
88
+ // Path data is y-down; the unit geometry is y-up.
89
+ for (let i = 0; i < p.length; i += 2) {
90
+ parts.push(i === 0 ? "M" : "L", coordinate(p[i] * scale), " ", coordinate(-p[i + 1] * scale))
91
+ }
92
+ if (contour.closed) parts.push("Z")
93
+ count++
94
+ points += p.length / 2
95
+ length += contour.length
96
+ if (count >= CONTOURS_PER_PATH || points >= POINTS_PER_PATH) flush()
97
+ }
98
+ flush()
99
+ return chunks
100
+ }
101
+
102
+ /** One of `values`, switched at the end of a `to`, with its choices listed as its `options`. */
103
+ function choice<const V extends readonly string[]>(values: V, initial: V[number]): Type<V[number]> {
104
+ return Types.custom<V[number], V[number], EntryOptions & { readonly options: V }>(initial, { lerp: snapValue, options: values })
105
+ }
106
+
107
+ /** A node's own drawing state, kept by its fields' view (one object per node, the same every frame). */
108
+ interface Kept {
109
+ drawn: Drawn | null
110
+ readonly warned: Set<string>
111
+ }
112
+
113
+ const kept = new WeakMap<object, Kept>()
114
+
115
+ function keptBy(state: object): Kept {
116
+ let entry = kept.get(state)
117
+ if (!entry) kept.set(state, (entry = { drawn: null, warned: new Set() }))
118
+ return entry
119
+ }
120
+
121
+ interface Selected {
122
+ readonly iso: readonly string[]
123
+ readonly level: GeoLevel
124
+ readonly frame: GeoFrame
125
+ readonly projection: GeoProjectionKind
126
+ readonly borders: GeoBorders
127
+ readonly detail: number
128
+ }
129
+
130
+ /** The selection's outlines cut into paths at `size`, or `null` while there is nothing to draw. */
131
+ function drawnOf(state: Selected, size: Size2D): Drawn | null {
132
+ const own = keptBy(state)
133
+ const codes = state.iso
134
+ const { level, frame } = state
135
+ const selection = selectGeoFeatures(codes, level, frame)
136
+ for (const code of selection.unknown) {
137
+ if (own.warned.has(code)) continue
138
+ own.warned.add(code)
139
+ console.warn(`[GeoBorder] no region with code ${code}`)
140
+ }
141
+ if (selection.features.length === 0) return null
142
+
143
+ const geometry = geoGeometry({
144
+ selectionKey: `${geoRegistry.generation}:${geoDataVersion()}:${level}:${frame}:${codes.join(",")}`,
145
+ features: selection.features,
146
+ frame,
147
+ projection: state.projection,
148
+ borders: state.borders,
149
+ detail: state.detail,
150
+ })
151
+ if (!geometry) return null
152
+
153
+ const { width, height } = size
154
+ const cached = own.drawn
155
+ if (cached && cached.geometry === geometry && cached.width === width && cached.height === height) return cached
156
+ const scale = Math.min(width / geometry.width, height / geometry.height)
157
+ const drawn: Drawn = {
158
+ geometry,
159
+ width,
160
+ height,
161
+ fills: chunk(geometry.fills, geometry.fillLength, scale),
162
+ lines: chunk(geometry.lines, geometry.lineLength, scale),
163
+ }
164
+ own.drawn = drawn
165
+ return drawn
166
+ }
167
+
168
+ /** Adds `chunk` to `g`, trimmed by `start`/`end` along the whole drawing; `false` when none of it is drawn. */
169
+ function pathOp(g: Graphics2D, chunk: Chunk, size: Size2D, start: number, end: number): boolean {
170
+ const { width, height } = size
171
+ const centerBounds: PathBounds = [-width / 2, -height / 2, width / 2, height / 2]
172
+ if (start <= 0 && end >= 1) {
173
+ g.path({ data: chunk.data, centerBounds })
174
+ return true
175
+ }
176
+ const span = chunk.to - chunk.from
177
+ if (span <= 0) return false
178
+ const localStart = Math.max(0, (start - chunk.from) / span)
179
+ const localEnd = Math.min(1, (end - chunk.from) / span)
180
+ if (localEnd <= localStart) return false
181
+ g.path({ data: chunk.data, centerBounds, start: localStart, end: localEnd })
182
+ return true
183
+ }
184
+
185
+ /**
186
+ * Country and subdivision outlines by ISO 3166 code, fitted into the node's box.
187
+ *
188
+ * <GeoBorder iso={["FR", "DE"]} detail={0.4} stroke={{ weight: 2, fill: "white" }} />
189
+ *
190
+ * Geometry comes from whatever `geoRegistry` has been given — a URL, a loader,
191
+ * or shapes defined by hand — and loads as a declared asset, so a frame never
192
+ * draws half a country. Fill and stroke are separate geometry: the fill is each
193
+ * region's polygons, the stroke is every border once, which is why a border two
194
+ * selected regions share is not drawn twice and why `stroke.align` does not
195
+ * apply.
196
+ */
197
+ export const GeoBorder = component2d({
198
+ key: "geoBorder",
199
+ state: {
200
+ ...Paint.state,
201
+ ...Flow.state,
202
+ iso: Types.custom<string | readonly string[], string[]>(["FR"], { resolve: normalizeIso, lerp: snapValue }),
203
+ level: choice(["country", "state"], "country"),
204
+ detail: Types.number(0.5, { min: 0, max: 1, step: 0.01 }),
205
+ projection: choice(["conic", "mercator", "equirectangular", "orthographic"], "conic"),
206
+ frame: choice(["mainland", "all"], "mainland"),
207
+ borders: choice(["all", "outline"], "all"),
208
+ },
209
+ commands: { ...Paint.commands },
210
+ // The data gates the draw, not layout: the box never waits on it.
211
+ load: (loader, state) => {
212
+ for (const file of geoFilesFor(state.iso, state.level)) loader.task(`geo:${file}`, geoLoader(file))
213
+ },
214
+ measure: (measurer, state, children) => Flow.measure(measurer, state, children, Paint.inset(state)),
215
+ layout: (box, state, children) => Flow.layout(box, state, children, Paint.inset(state)),
216
+ render: (ctx, state) => {
217
+ const size = ctx.size
218
+ const { start, end } = state
219
+ const fill = state.fill as FillResolved[]
220
+ const shadow = state.shadow as ShadowResolved[]
221
+ const drawn = () => drawnOf(state, size)
222
+
223
+ ctx.inFront(() => {
224
+ const shape = drawn()
225
+ if (!shape || shape.fills.length === 0) return
226
+ const g = new Graphics2D()
227
+ for (const part of shape.fills) {
228
+ if (!pathOp(g, part, size, start, end)) continue
229
+ if (shadow.length > 0) g.shadow(shadow)
230
+ g.fill(fill)
231
+ }
232
+ ctx.draw(g)
233
+ })
234
+
235
+ // The box, not the outline, confines the children and is what picking
236
+ // tests: a path clip has no winding test, and a country's bounding box is
237
+ // what the selection shows.
238
+ Paint.draw(ctx, state, null, { clipShape: Shapes.box(size) })
239
+
240
+ ctx.inFront(() => {
241
+ const overlay = state.overlay as FillResolved[]
242
+ if (overlay.length > 0) {
243
+ const shape = drawn()
244
+ if (shape) {
245
+ const g = new Graphics2D()
246
+ for (const part of shape.fills) if (pathOp(g, part, size, start, end)) g.fill(overlay)
247
+ ctx.draw(g)
248
+ }
249
+ }
250
+
251
+ const stroke = state.stroke as StrokeResolved[]
252
+ if (stroke.length === 0) return
253
+ const shape = drawn()
254
+ if (!shape || shape.lines.length === 0) return
255
+ const centred = stroke.map((layer) => (layer.align === 0 ? layer : { ...layer, align: 0 }))
256
+ const castsShadow = shadow.length > 0 && !fill.some((layer) => (layer.opacity ?? 1) > 0)
257
+ const g = new Graphics2D()
258
+ for (const part of shape.lines) {
259
+ if (!pathOp(g, part, size, start, end)) continue
260
+ if (castsShadow) g.shadow(shadow)
261
+ g.stroke(centred)
262
+ }
263
+ ctx.draw(g)
264
+ })
265
+ },
266
+ })
267
+
268
+ export type GeoBorder = NodeOf<typeof GeoBorder>