@diagrammo/dgmo 0.20.3 → 0.21.1

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 (48) hide show
  1. package/dist/advanced.cjs +867 -286
  2. package/dist/advanced.js +866 -286
  3. package/dist/auto.cjs +635 -284
  4. package/dist/auto.js +113 -113
  5. package/dist/auto.mjs +635 -284
  6. package/dist/cli.cjs +156 -156
  7. package/dist/editor.cjs +6 -2
  8. package/dist/editor.js +6 -2
  9. package/dist/highlight.cjs +6 -2
  10. package/dist/highlight.js +6 -2
  11. package/dist/index.cjs +628 -281
  12. package/dist/index.js +628 -281
  13. package/dist/internal.cjs +867 -286
  14. package/dist/internal.js +866 -286
  15. package/dist/map-data/PROVENANCE.json +1 -1
  16. package/dist/map-data/mountain-ranges.json +1 -0
  17. package/docs/language-reference.md +27 -25
  18. package/gallery/fixtures/map-choropleth.dgmo +7 -7
  19. package/gallery/fixtures/map-direct-color.dgmo +10 -0
  20. package/gallery/fixtures/map-pois.dgmo +4 -4
  21. package/gallery/fixtures/map-region-scope.dgmo +8 -8
  22. package/gallery/fixtures/map-route.dgmo +5 -6
  23. package/package.json +1 -1
  24. package/src/advanced.ts +14 -0
  25. package/src/completion.ts +10 -4
  26. package/src/d3.ts +15 -9
  27. package/src/editor/keywords.ts +6 -2
  28. package/src/map/data/PROVENANCE.json +1 -1
  29. package/src/map/data/mountain-ranges.json +1 -0
  30. package/src/map/geo-query.ts +277 -0
  31. package/src/map/geo.ts +258 -1
  32. package/src/map/invert.ts +111 -0
  33. package/src/map/layout.ts +333 -139
  34. package/src/map/load-data.ts +7 -1
  35. package/src/map/parser.ts +142 -33
  36. package/src/map/renderer.ts +57 -6
  37. package/src/map/resolved-types.ts +21 -2
  38. package/src/map/resolver.ts +219 -53
  39. package/src/map/types.ts +57 -14
  40. package/src/utils/reserved-key-registry.ts +7 -7
  41. package/dist/advanced.d.cts +0 -5290
  42. package/dist/advanced.d.ts +0 -5290
  43. package/dist/auto.d.cts +0 -39
  44. package/dist/auto.d.ts +0 -39
  45. package/dist/index.d.cts +0 -336
  46. package/dist/index.d.ts +0 -336
  47. package/dist/internal.d.cts +0 -5290
  48. package/dist/internal.d.ts +0 -5290
@@ -43,6 +43,7 @@ const FILES = {
43
43
  usStates: 'us-states.json',
44
44
  lakes: 'lakes.json',
45
45
  rivers: 'rivers.json',
46
+ mountainRanges: 'mountain-ranges.json',
46
47
  naLand: 'na-land.json',
47
48
  naLakes: 'na-lakes.json',
48
49
  gazetteer: 'gazetteer.json',
@@ -131,6 +132,7 @@ export function loadMapData(): Promise<MapData> {
131
132
  usStates,
132
133
  lakes,
133
134
  rivers,
135
+ mountainRanges,
134
136
  naLand,
135
137
  naLakes,
136
138
  gazetteer,
@@ -138,9 +140,12 @@ export function loadMapData(): Promise<MapData> {
138
140
  readJson<BoundaryTopology>(nb, dir, FILES.worldCoarse),
139
141
  readJson<BoundaryTopology>(nb, dir, FILES.worldDetail),
140
142
  readJson<BoundaryTopology>(nb, dir, FILES.usStates),
141
- // Lakes/rivers/NA assets are optional — older bundles may predate them.
143
+ // Lakes/rivers/mountain/NA assets are optional — older bundles may predate them.
142
144
  readJson<BoundaryTopology>(nb, dir, FILES.lakes).catch(() => undefined),
143
145
  readJson<BoundaryTopology>(nb, dir, FILES.rivers).catch(() => undefined),
146
+ readJson<BoundaryTopology>(nb, dir, FILES.mountainRanges).catch(
147
+ () => undefined
148
+ ),
144
149
  readJson<BoundaryTopology>(nb, dir, FILES.naLand).catch(() => undefined),
145
150
  readJson<BoundaryTopology>(nb, dir, FILES.naLakes).catch(() => undefined),
146
151
  readJson<Gazetteer>(nb, dir, FILES.gazetteer),
@@ -152,6 +157,7 @@ export function loadMapData(): Promise<MapData> {
152
157
  gazetteer,
153
158
  ...(lakes && { lakes }),
154
159
  ...(rivers && { rivers }),
160
+ ...(mountainRanges && { mountainRanges }),
155
161
  ...(naLand && { naLand }),
156
162
  ...(naLakes && { naLakes }),
157
163
  });
package/src/map/parser.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  measureIndent,
10
10
  splitNameAndMeta,
11
11
  extractColor,
12
+ peelTrailingColorName,
12
13
  } from '../utils/parsing';
13
14
  import {
14
15
  MAP_REGISTRY,
@@ -28,7 +29,7 @@ import type {
28
29
  MapRegion,
29
30
  MapPoi,
30
31
  MapRoute,
31
- MapRouteStop,
32
+ MapRouteLeg,
32
33
  MapEdge,
33
34
  PoiPos,
34
35
  MapScale,
@@ -42,13 +43,16 @@ const SCOPE_RE = /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/;
42
43
  // whitespace, so hyphens inside names (`office-east`) and `foo-bar` are safe.
43
44
  const ARROW_SPLIT = /\s+(-[^>]*?->|->|~[^>]*?~>|~>|--)\s+/;
44
45
  const HUB_RE = /^(->|~>)\s+(.+)$/;
46
+ // A route leg line: an optional leading arrow (with in-arrow label) + a destination.
47
+ const LEG_ARROW_RE = /^(-[^>]*?->|->|~[^>]*?~>|~>|--)\s+(.+)$/;
45
48
  const AT_RE = /(^|[\s,])at\s*:/i; // the removed `at:` coord form (§24B.9)
46
49
 
47
50
  const DIRECTIVE_SET: ReadonlySet<string> = new Set([
48
51
  'region',
49
52
  'projection',
50
- 'metric',
51
- 'size-metric',
53
+ 'region-metric',
54
+ 'poi-metric',
55
+ 'flow-metric',
52
56
  'scale',
53
57
  'region-labels',
54
58
  'poi-labels',
@@ -56,6 +60,8 @@ const DIRECTIVE_SET: ReadonlySet<string> = new Set([
56
60
  'default-state',
57
61
  'active-tag',
58
62
  'no-legend',
63
+ 'no-insets',
64
+ 'relief',
59
65
  'subtitle',
60
66
  'caption',
61
67
  ]);
@@ -177,9 +183,10 @@ export function parseMap(content: string): ParsedMap {
177
183
  addTagEntry(open.tag, trimmed, lineNumber);
178
184
  continue;
179
185
  }
180
- // (1a) Indented child of an open route → a stop.
186
+ // (1a) Indented child of an open route → a leg (an edge from the prev stop).
181
187
  if (open.route && indent > open.route.indent) {
182
- open.route.route.stops.push(parseStop(trimmed, lineNumber));
188
+ const leg = parseLeg(trimmed, lineNumber, open.route.route.style);
189
+ (open.route.route.legs as MapRouteLeg[]).push(leg);
183
190
  continue;
184
191
  }
185
192
  // (1b) Indented child of an open POI → hub edge or extra metadata.
@@ -217,6 +224,15 @@ export function parseMap(content: string): ParsedMap {
217
224
  handleTag(trimmed, lineNumber);
218
225
  continue;
219
226
  }
227
+ // Bare-flag directives (no value) — only when the line is exactly the flag,
228
+ // so a region named e.g. "Natural Bridge" still parses as a region.
229
+ if (
230
+ (firstWord === 'muted' || firstWord === 'natural') &&
231
+ trimmed === firstWord
232
+ ) {
233
+ handleDirective(firstWord, '', lineNumber);
234
+ continue;
235
+ }
220
236
  if (
221
237
  DIRECTIVE_SET.has(firstWord) &&
222
238
  !trimmed.slice(firstWord.length).trimStart().startsWith(':')
@@ -297,13 +313,23 @@ export function parseMap(content: string): ParsedMap {
297
313
  );
298
314
  d.projection = value;
299
315
  break;
300
- case 'metric':
301
- dup(d.metric);
302
- d.metric = value;
316
+ case 'region-metric': {
317
+ dup(d.regionMetric);
318
+ // A trailing color names the choropleth ramp hue (§24B.3): the
319
+ // label keeps the rest. `region-metric Sales ($M) blue` → blue ramp.
320
+ const { label: rmLabel, colorName: rmColor } =
321
+ peelTrailingColorName(value);
322
+ d.regionMetric = rmLabel;
323
+ if (rmColor) d.regionMetricColor = rmColor;
303
324
  break;
304
- case 'size-metric':
305
- dup(d.sizeMetric);
306
- d.sizeMetric = value;
325
+ }
326
+ case 'poi-metric':
327
+ dup(d.poiMetric);
328
+ d.poiMetric = value;
329
+ break;
330
+ case 'flow-metric':
331
+ dup(d.flowMetric);
332
+ d.flowMetric = value;
307
333
  break;
308
334
  case 'scale':
309
335
  dup(d.scale);
@@ -345,6 +371,22 @@ export function parseMap(content: string): ParsedMap {
345
371
  case 'no-legend':
346
372
  d.noLegend = true;
347
373
  break;
374
+ case 'no-insets':
375
+ d.noInsets = true;
376
+ break;
377
+ case 'relief':
378
+ // Bare flag (idempotent like no-insets — `relief\nrelief` is no warning).
379
+ d.relief = true;
380
+ break;
381
+ case 'muted':
382
+ case 'natural':
383
+ if (d.basemapStyle !== undefined && d.basemapStyle !== key)
384
+ pushWarning(
385
+ line,
386
+ `Conflicting basemap dress — "${d.basemapStyle}" then "${key}"; last wins.`
387
+ );
388
+ d.basemapStyle = key;
389
+ break;
348
390
  case 'subtitle':
349
391
  dup(d.subtitle);
350
392
  d.subtitle = value;
@@ -431,18 +473,18 @@ export function parseMap(content: string): ParsedMap {
431
473
  line
432
474
  );
433
475
  const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
434
- let scoreNum: number | undefined;
435
- const score = meta['score'];
436
- if (score !== undefined) {
437
- delete (meta as Record<string, string>)['score']; // lifted out of inert meta
438
- scoreNum = Number(score);
439
- if (!Number.isFinite(scoreNum)) {
440
- pushError(line, `score must be a number (got "${score}").`);
441
- scoreNum = undefined;
476
+ let valueNum: number | undefined;
477
+ const value = meta['value'];
478
+ if (value !== undefined) {
479
+ delete (meta as Record<string, string>)['value']; // lifted out of meta
480
+ valueNum = Number(value);
481
+ if (!Number.isFinite(valueNum)) {
482
+ pushError(line, `value must be a number (got "${value}").`);
483
+ valueNum = undefined;
442
484
  }
443
485
  }
444
- // A region may carry BOTH a `score:` and a tag value — they are two
445
- // selectable colouring dimensions (the legend flips between the score ramp
486
+ // A region may carry BOTH a `value:` and a tag value — they are two
487
+ // selectable colouring dimensions (the legend flips between the value ramp
446
488
  // and the tag group), so this is no longer warned (bivariate is handled).
447
489
  // Peel a trailing ISO scope token (§24B.8) — same qualifier POIs accept,
448
490
  // so `Georgia US-GA` / `Georgia US` can force the country-vs-state pick.
@@ -461,7 +503,9 @@ export function parseMap(content: string): ParsedMap {
461
503
  lineNumber: line,
462
504
  };
463
505
  if (regionScope !== undefined) region.scope = regionScope;
464
- if (scoreNum !== undefined) region.score = scoreNum;
506
+ if (valueNum !== undefined) region.value = valueNum;
507
+ // §1.5 trailing color → flat categorical override fill (§24B.4).
508
+ if (split.color) region.color = split.color;
465
509
  regions.push(region);
466
510
  }
467
511
 
@@ -482,35 +526,100 @@ export function parseMap(content: string): ParsedMap {
482
526
  const pos = parsePos(split.name, line);
483
527
  if (!pos) return; // error already pushed
484
528
  const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
485
- const label = meta['label']; // label lifted out of meta; `score`/`size`/`date` stay inert (#2)
529
+ const label = meta['label']; // label lifted out of meta; `value` (→ marker size) stays in meta
486
530
  if (label !== undefined) delete (meta as Record<string, string>)['label'];
487
531
  const poi: Writable<MapPoi> = { pos, tags, meta, lineNumber: line };
488
532
  if (split.alias) poi.alias = split.alias;
489
533
  if (label !== undefined) poi.label = label;
534
+ // §1.5 trailing color → flat marker fill (§24B.5); wins over a tag color.
535
+ if (split.color) poi.color = split.color;
490
536
  pois.push(poi);
491
537
  open.poi = { poi, indent };
492
538
  }
493
539
 
494
540
  function handleRoute(rest: string, line: number, indent: number): void {
495
- const meta = rest ? splitNameAndMeta(rest, registry(), aliasMap).meta : {};
496
- const route: Writable<MapRoute> = { stops: [], meta, lineNumber: line };
541
+ const split = rest
542
+ ? splitNameAndMeta(
543
+ rest,
544
+ registry(),
545
+ aliasMap,
546
+ undefined,
547
+ diagnostics,
548
+ line
549
+ )
550
+ : { name: '', meta: {} as Record<string, string>, alias: undefined };
551
+ const pos = parsePos(split.name, line);
552
+ if (!pos || (pos.kind === 'name' && !pos.name)) {
553
+ pushError(
554
+ line,
555
+ 'route requires an origin: `route <origin> [style: arc]`.'
556
+ );
557
+ return;
558
+ }
559
+ const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
560
+ const originLabel = meta['label'];
561
+ const originValue = meta['value'];
562
+ const style: 'straight' | 'arc' =
563
+ meta['style'] === 'arc' ? 'arc' : 'straight';
564
+ const route: Writable<MapRoute> = {
565
+ origin: pos,
566
+ ...(split.alias !== undefined && { originAlias: split.alias }),
567
+ ...(originLabel !== undefined && { originLabel }),
568
+ ...(originValue !== undefined && { originValue }),
569
+ originTags: tags,
570
+ style,
571
+ legs: [],
572
+ lineNumber: line,
573
+ };
497
574
  routes.push(route);
498
575
  open.route = { route, indent };
499
576
  }
500
577
 
501
- function parseStop(trimmed: string, line: number): MapRouteStop {
502
- const split = splitNameAndMeta(trimmed, registry(), aliasMap);
503
- const ref = parsePos(split.name, line) ?? {
578
+ /** Parse one route body line into a leg: `[-label->] <destination> [keys]`.
579
+ * The arrow (if any) gives the leg label + shape; `value:` is leg thickness;
580
+ * a tag / `label:` decorate the destination stop. Bare `<dest>` = plain leg. */
581
+ function parseLeg(
582
+ trimmed: string,
583
+ line: number,
584
+ headerStyle: 'straight' | 'arc'
585
+ ): MapRouteLeg {
586
+ let arrowStyle: 'straight' | 'arc' = 'straight';
587
+ let label: string | undefined;
588
+ let rest = trimmed;
589
+ const m = trimmed.match(LEG_ARROW_RE);
590
+ if (m) {
591
+ const arr = classifyArrow(m[1]!, line);
592
+ arrowStyle = arr.style;
593
+ label = arr.label;
594
+ rest = m[2]!;
595
+ }
596
+ const split = splitNameAndMeta(
597
+ rest,
598
+ registry(),
599
+ aliasMap,
600
+ undefined,
601
+ diagnostics,
602
+ line
603
+ );
604
+ const pos = parsePos(split.name, line) ?? {
504
605
  kind: 'name',
505
606
  name: split.name,
506
607
  };
507
- const stop: Writable<MapRouteStop> = {
508
- ref,
509
- meta: split.meta,
608
+ const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
609
+ const value = meta['value'];
610
+ const destLabel = meta['label'];
611
+ const style: 'straight' | 'arc' =
612
+ arrowStyle === 'arc' || headerStyle === 'arc' ? 'arc' : 'straight';
613
+ return {
614
+ ...(label !== undefined && { label }),
615
+ style,
616
+ ...(value !== undefined && { value }),
617
+ dest: pos,
618
+ ...(split.alias !== undefined && { destAlias: split.alias }),
619
+ ...(destLabel !== undefined && { destLabel }),
620
+ destTags: tags,
510
621
  lineNumber: line,
511
622
  };
512
- if (split.alias) stop.alias = split.alias;
513
- return stop;
514
623
  }
515
624
 
516
625
  function handleEdges(trimmed: string, line: number): void {
@@ -101,11 +101,11 @@ export function renderMap(
101
101
  .attr('stroke', r.stroke)
102
102
  .attr('stroke-width', strokeWidth);
103
103
  // Data layer? Tag it so the app can highlight on legend hover / gradient
104
- // scrub. `data-score` for ramp-proximity, `data-tag-<group>` per tag value
104
+ // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
105
105
  // (both lowercased to match the lowercased legend-entry attributes).
106
106
  if (r.layer !== 'base') {
107
107
  p.classed('dgmo-map-region', true).attr('data-region', r.id);
108
- if (r.score !== undefined) p.attr('data-score', r.score);
108
+ if (r.value !== undefined) p.attr('data-value', r.value);
109
109
  if (r.tags) {
110
110
  for (const [group, value] of Object.entries(r.tags)) {
111
111
  p.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
@@ -123,6 +123,49 @@ export function renderMap(
123
123
  };
124
124
  for (const r of layout.regions) drawRegion(gRegions, r, 0.5);
125
125
 
126
+ // ── Relief (mountain-range hachure over ALL land, under rivers/POIs/labels) ──
127
+ // Rule horizontal lines across the whole canvas, clipped to the INTERSECTION
128
+ // of (a) the union of range polygons and (b) the land — nested clipPaths, so
129
+ // the hachure never bleeds onto water (coarse range polygons overrun the
130
+ // coast, and horizontal lines on the sea read as the water convention). The
131
+ // land clip is every drawn region except lakes — INCLUDING value-/tag-coloured
132
+ // regions, so the relief texture sits ATOP the choropleth/tag fills (a range
133
+ // crossing a valued state still reads as mountains there). It stays below
134
+ // rivers, POIs, and labels. Explicit <line>s in a <clipPath> (not a tiled
135
+ // <pattern>) dodge WKWebView/resvg pattern quirks. A non-scaling stroke keeps
136
+ // the width constant in device px at any zoom/DPR (uniform, no moire); kept
137
+ // sub-pixel + low-contrast so the texture stays faint. Decorative — no data attrs.
138
+ if (layout.relief.length && layout.reliefHatch) {
139
+ const h = layout.reliefHatch;
140
+ const rangeClipId = 'dgmo-relief-clip';
141
+ const landClipId = 'dgmo-relief-land';
142
+ const rangeClip = defs.append('clipPath').attr('id', rangeClipId);
143
+ for (const s of layout.relief) rangeClip.append('path').attr('d', s.d);
144
+ const landClip = defs.append('clipPath').attr('id', landClipId);
145
+ for (const r of layout.regions)
146
+ if (r.id !== 'lake') landClip.append('path').attr('d', r.d);
147
+ const gRelief = svg
148
+ .append('g')
149
+ .attr('clip-path', `url(#${landClipId})`) // outer: land only
150
+ .append('g')
151
+ .attr('class', 'dgmo-map-relief')
152
+ .attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
153
+ .attr('stroke', h.color)
154
+ .attr('stroke-width', h.width)
155
+ // Non-scaling stroke = constant device width at any zoom/DPR (uniform,
156
+ // no moire). NOT crispEdges — that snaps to a solid ~1px in WebKit and
157
+ // reads far too heavy; plain AA keeps the sub-pixel lines whisper-thin.
158
+ .attr('vector-effect', 'non-scaling-stroke');
159
+ for (let y = h.spacing; y < height; y += h.spacing) {
160
+ gRelief
161
+ .append('line')
162
+ .attr('x1', 0)
163
+ .attr('y1', y)
164
+ .attr('x2', width)
165
+ .attr('y2', y);
166
+ }
167
+ }
168
+
126
169
  // ── Rivers (thin water centerlines over the land, under POIs/edges) ──
127
170
  if (layout.rivers.length) {
128
171
  const gRivers = svg
@@ -230,6 +273,13 @@ export function renderMap(
230
273
  .attr('stroke-width', 1)
231
274
  .attr('data-line-number', poi.lineNumber)
232
275
  .attr('data-poi', poi.id);
276
+ // Tag the marker per tag value (lowercased, matching the lowercased
277
+ // legend-entry attributes) so the app can spotlight it on legend hover.
278
+ if (poi.tags) {
279
+ for (const [group, value] of Object.entries(poi.tags)) {
280
+ c.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
281
+ }
282
+ }
233
283
  if (onClickItem) {
234
284
  c.style('cursor', 'pointer').on('click', () =>
235
285
  onClickItem(poi.lineNumber)
@@ -296,14 +346,14 @@ export function renderMap(
296
346
  .append('g')
297
347
  .attr('class', 'dgmo-map-legend')
298
348
  .attr('transform', `translate(0, ${legendY})`);
299
- // The score ramp is a selectable colouring group alongside the tag groups
349
+ // The value ramp is a selectable colouring group alongside the tag groups
300
350
  // (the user flips between them); its capsule renders the gradient inline.
301
- // Reserved name "Score" when no metric label is set — must match SCORE_NAME
302
- // in layout.ts so the resolved activeGroup selects it.
351
+ // Reserved name "Value" when no region-metric label is set — must match
352
+ // VALUE_NAME in layout.ts so the resolved activeGroup selects it.
303
353
  const ramp = layout.legend.ramp;
304
354
  const scoreGroup = ramp
305
355
  ? {
306
- name: ramp.metric?.trim() || 'Score',
356
+ name: ramp.metric?.trim() || 'Value',
307
357
  entries: [],
308
358
  gradient: {
309
359
  min: ramp.min,
@@ -340,6 +390,7 @@ export function renderMap(
340
390
  if (layout.title) {
341
391
  svg
342
392
  .append('text')
393
+ .attr('class', 'dgmo-map-title')
343
394
  .attr('x', width / 2)
344
395
  .attr('y', TITLE_Y)
345
396
  .attr('text-anchor', 'middle')
@@ -17,6 +17,10 @@ export interface MapData {
17
17
  /** Major river centerlines (Natural Earth 110m) drawn as thin water lines over
18
18
  * land — e.g. the Amazon, Nile, Mississippi. Optional, like `lakes`. */
19
19
  rivers?: BoundaryTopology;
20
+ /** Notable mountain-range polygons (Natural Earth 50m geography regions) drawn
21
+ * as a subtle gradient relief cue over base land when the `relief` directive
22
+ * is on — e.g. the Rockies, Andes, Himalayas. Optional, like `lakes`. */
23
+ mountainRanges?: BoundaryTopology;
20
24
  /** North-America-clipped 10m country land, used as crisp neighbour context
21
25
  * under the albers-usa US view so Canada/Mexico match the 10m states instead
22
26
  * of the coarser world tiers. Optional, like `lakes`. */
@@ -46,7 +50,9 @@ export interface ResolvedRegion {
46
50
  readonly iso: string;
47
51
  readonly name: string; // display name
48
52
  readonly layer: 'country' | 'us-state';
49
- readonly score?: number;
53
+ readonly value?: number;
54
+ /** §1.5 trailing-token color NAME → flat override fill (§24B.4). */
55
+ readonly color?: string;
50
56
  readonly tags: Readonly<Record<string, string>>;
51
57
  readonly meta: Readonly<Record<string, string>>;
52
58
  readonly lineNumber: number;
@@ -62,6 +68,8 @@ export interface ResolvedPoi {
62
68
  readonly lat: number;
63
69
  readonly lon: number;
64
70
  readonly label?: string;
71
+ /** §1.5 trailing-token color NAME → flat marker fill (§24B.5). */
72
+ readonly color?: string;
65
73
  readonly tags: Readonly<Record<string, string>>;
66
74
  readonly meta: Readonly<Record<string, string>>;
67
75
  readonly lineNumber: number;
@@ -79,9 +87,20 @@ export interface ResolvedEdge {
79
87
  readonly lineNumber: number;
80
88
  }
81
89
 
90
+ export interface ResolvedRouteLeg {
91
+ readonly fromId: string;
92
+ readonly toId: string;
93
+ readonly label?: string;
94
+ readonly style: 'straight' | 'arc';
95
+ readonly value?: string; // leg thickness
96
+ readonly lineNumber: number;
97
+ }
98
+
82
99
  export interface ResolvedRoute {
100
+ /** Ordered UNIQUE stop ids (for numbering + the origin marker). A loop-closing
101
+ * leg whose destination is an earlier stop adds a leg but no duplicate stop. */
83
102
  readonly stopIds: readonly string[];
84
- readonly meta: Readonly<Record<string, string>>;
103
+ readonly legs: readonly ResolvedRouteLeg[];
85
104
  readonly lineNumber: number;
86
105
  }
87
106