@diagrammo/dgmo 0.32.1 → 0.34.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.
Files changed (60) hide show
  1. package/.cursorrules +7 -2
  2. package/.github/copilot-instructions.md +7 -2
  3. package/.windsurfrules +7 -2
  4. package/SKILL.md +7 -2
  5. package/dist/advanced.cjs +1434 -443
  6. package/dist/advanced.d.cts +41 -1
  7. package/dist/advanced.d.ts +41 -1
  8. package/dist/advanced.js +1433 -443
  9. package/dist/auto.cjs +1242 -318
  10. package/dist/auto.js +91 -91
  11. package/dist/auto.mjs +1242 -318
  12. package/dist/cli.cjs +138 -136
  13. package/dist/index.cjs +1308 -317
  14. package/dist/index.d.cts +35 -1
  15. package/dist/index.d.ts +35 -1
  16. package/dist/index.js +1307 -317
  17. package/docs/ai-integration.md +7 -2
  18. package/docs/language-reference.md +39 -8
  19. package/package.json +2 -2
  20. package/src/advanced.ts +6 -1
  21. package/src/boxes-and-lines/label-placement.ts +339 -0
  22. package/src/boxes-and-lines/layout-grouped.ts +909 -0
  23. package/src/boxes-and-lines/layout-search.ts +34 -3
  24. package/src/boxes-and-lines/layout.ts +32 -7
  25. package/src/boxes-and-lines/parser.ts +3 -2
  26. package/src/boxes-and-lines/renderer.ts +67 -139
  27. package/src/c4/layout.ts +2 -1
  28. package/src/c4/parser.ts +3 -2
  29. package/src/c4/renderer.ts +3 -2
  30. package/src/cli.ts +68 -0
  31. package/src/er/parser.ts +2 -1
  32. package/src/er/renderer.ts +2 -1
  33. package/src/gantt/parser.ts +3 -5
  34. package/src/gantt/renderer.ts +7 -6
  35. package/src/index.ts +6 -1
  36. package/src/infra/parser.ts +2 -1
  37. package/src/infra/renderer.ts +4 -3
  38. package/src/journey-map/parser.ts +27 -8
  39. package/src/journey-map/renderer.ts +26 -9
  40. package/src/kanban/parser.ts +4 -3
  41. package/src/kanban/renderer.ts +6 -5
  42. package/src/map/completion.ts +116 -0
  43. package/src/map/layout.ts +4 -3
  44. package/src/map/parser.ts +3 -2
  45. package/src/mindmap/parser.ts +4 -3
  46. package/src/mindmap/renderer.ts +2 -1
  47. package/src/org/layout.ts +3 -2
  48. package/src/org/parser.ts +3 -2
  49. package/src/org/renderer.ts +5 -4
  50. package/src/pert/parser.ts +3 -2
  51. package/src/sequence/renderer.ts +10 -12
  52. package/src/sequence/tag-resolution.ts +2 -1
  53. package/src/sitemap/layout.ts +3 -2
  54. package/src/sitemap/parser.ts +36 -2
  55. package/src/sitemap/renderer.ts +12 -6
  56. package/src/timeline/renderer.ts +4 -3
  57. package/src/utils/legend-d3.ts +4 -3
  58. package/src/utils/legend-svg.ts +3 -2
  59. package/src/utils/tag-groups.ts +28 -7
  60. package/src/visualizations/parse.ts +2 -1
@@ -2,6 +2,7 @@
2
2
  // Gantt Chart Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Scale from 'd3-scale';
6
7
  import * as d3Selection from 'd3-selection';
7
8
  import { FONT_FAMILY } from '../fonts';
@@ -660,7 +661,7 @@ export function renderGantt(
660
661
  (tg) => tg.name.toLowerCase() === currentSwimlaneGroup.toLowerCase()
661
662
  );
662
663
  if (tagGroup) {
663
- const tagKey = tagGroup.name.toLowerCase();
664
+ const tagKey = tagAttrKey(tagGroup.name);
664
665
  for (const rt of resolved.tasks) {
665
666
  let value = rt.effectiveMetadata[tagKey];
666
667
  if (!value && tagGroup.defaultValue) value = tagGroup.defaultValue;
@@ -2091,7 +2092,7 @@ function renderTagLegend(
2091
2092
  const usedValues = new Map<string, Set<string>>();
2092
2093
  if (resolvedTasks) {
2093
2094
  for (const group of visibleGroups) {
2094
- const key = group.name.toLowerCase();
2095
+ const key = tagAttrKey(group.name);
2095
2096
  const used = new Set<string>();
2096
2097
  for (const rt of resolvedTasks) {
2097
2098
  const val = rt.effectiveMetadata[key];
@@ -2104,7 +2105,7 @@ function renderTagLegend(
2104
2105
  // Filter entries to only those used in the current view
2105
2106
  const filteredEntries = new Map<string, readonly TagEntry[]>();
2106
2107
  for (const group of visibleGroups) {
2107
- const key = group.name.toLowerCase();
2108
+ const key = tagAttrKey(group.name);
2108
2109
  const used = usedValues.get(key);
2109
2110
  if (used && used.size > 0) {
2110
2111
  filteredEntries.set(
@@ -2133,7 +2134,7 @@ function renderTagLegend(
2133
2134
  let groupW = pillW;
2134
2135
  if (isActive) {
2135
2136
  const entries =
2136
- filteredEntries.get(group.name.toLowerCase()) ?? group.entries;
2137
+ filteredEntries.get(tagAttrKey(group.name)) ?? group.entries;
2137
2138
  let entriesW = 0;
2138
2139
  for (const entry of entries) {
2139
2140
  entriesW +=
@@ -2215,7 +2216,7 @@ function renderTagLegend(
2215
2216
  }),
2216
2217
  ...(onControlsToggle !== undefined && { onControlsToggle }),
2217
2218
  onEntryHover: (groupName, entryValue) => {
2218
- const tagKey = groupName.toLowerCase();
2219
+ const tagKey = tagAttrKey(groupName);
2219
2220
  if (entryValue) {
2220
2221
  const ev = entryValue.toLowerCase();
2221
2222
  chartG
@@ -3388,7 +3389,7 @@ export function buildTagLaneRowList(
3388
3389
  );
3389
3390
  if (!tagGroup) return null;
3390
3391
 
3391
- const tagKey = tagGroup.name.toLowerCase();
3392
+ const tagKey = tagAttrKey(tagGroup.name);
3392
3393
  const rows: Row[] = [];
3393
3394
 
3394
3395
  // Bucket tasks by tag value
package/src/index.ts CHANGED
@@ -226,11 +226,16 @@ export { normalizeSvgForEmbed, getEmbedSvgViewBox } from './utils/svg-embed';
226
226
  // ============================================================
227
227
  // Pure + dependency-injected: the caller supplies the `Gazetteer` asset. Safe
228
228
  // to export from the main index — no d3-geo/topojson runtime imports.
229
- export { completeMapPlaces, completeMapRegions } from './map/completion';
229
+ export {
230
+ completeMapPlaces,
231
+ completeMapRegions,
232
+ searchMapLocations,
233
+ } from './map/completion';
230
234
  export type {
231
235
  MapPlaceCompletion,
232
236
  MapRegionCompletion,
233
237
  MapCompletionOptions,
238
+ MapLocationMatch,
234
239
  } from './map/completion';
235
240
  export type {
236
241
  Gazetteer,
@@ -6,6 +6,7 @@
6
6
  // Handles: chart metadata, component blocks with indented properties
7
7
  // and connections, [Group] containers, tag groups, pipe metadata.
8
8
 
9
+ import { tagAttrKey } from '../utils/tag-groups';
9
10
  import {
10
11
  descriptionBareRemovedMessage,
11
12
  formatDgmoError,
@@ -1054,7 +1055,7 @@ export function parseInfra(content: string): ParsedInfra {
1054
1055
  // Inject default tag values into nodes that don't have one
1055
1056
  for (const tg of result.tagGroups) {
1056
1057
  if (!tg.defaultValue) continue;
1057
- const key = (tg.alias ?? tg.name).toLowerCase();
1058
+ const key = tagAttrKey(tg.alias ?? tg.name);
1058
1059
  for (const node of result.nodes) {
1059
1060
  if (node.isEdge) continue;
1060
1061
  if (!(key in node.tags)) {
@@ -2,6 +2,7 @@
2
2
  // Infra Chart SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import * as d3Shape from 'd3-shape';
7
8
  import { FONT_FAMILY } from '../fonts';
@@ -1388,7 +1389,7 @@ function resolveActiveTagStroke(
1388
1389
  (t) => t.name.toLowerCase() === activeGroup.toLowerCase()
1389
1390
  );
1390
1391
  if (!tg) return null;
1391
- const tagKey = (tg.alias ?? tg.name).toLowerCase();
1392
+ const tagKey = tagAttrKey(tg.alias ?? tg.name);
1392
1393
  const tagVal = node.tags[tagKey];
1393
1394
  if (!tagVal) return null;
1394
1395
  const tv = tg.values.find(
@@ -2073,7 +2074,7 @@ export function computeInfraLegendGroups(
2073
2074
  groups.push({
2074
2075
  name: tg.name,
2075
2076
  type: 'tag',
2076
- tagKey: (tg.alias ?? tg.name).toLowerCase(),
2077
+ tagKey: tagAttrKey(tg.alias ?? tg.name),
2077
2078
  entries,
2078
2079
  width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
2079
2080
  minifiedWidth: pillWidth,
@@ -2146,7 +2147,7 @@ function renderLegend(
2146
2147
  // Add infra-specific classes and data attributes for app interactivity
2147
2148
  legendG.selectAll('[data-legend-group]').classed('infra-legend-group', true);
2148
2149
  for (const group of legendGroups) {
2149
- const groupKey = group.name.toLowerCase();
2150
+ const groupKey = tagAttrKey(group.name);
2150
2151
  for (const entry of group.entries) {
2151
2152
  const entryEl = legendG.select(
2152
2153
  `[data-legend-group="${groupKey}"] [data-legend-entry="${entry.value.toLowerCase()}"]`
@@ -14,7 +14,11 @@ import {
14
14
  JOURNEY_MAP_REGISTRY,
15
15
  withTagAliases,
16
16
  } from '../utils/reserved-key-registry';
17
- import { splitNameAndMeta, warnUnknownMetaKeys } from '../utils/parsing';
17
+ import {
18
+ splitNameAndMeta,
19
+ warnUnknownMetaKeys,
20
+ peelTrailingColorName,
21
+ } from '../utils/parsing';
18
22
  import {
19
23
  matchTagBlockHeading,
20
24
  emitTagLegacyDiagnostic,
@@ -22,6 +26,7 @@ import {
22
26
  validateTagGroupNames,
23
27
  finalizeAutoTagColors,
24
28
  AUTO_TAG_COLOR_SENTINEL,
29
+ tagAttrKey,
25
30
  } from '../utils/tag-groups';
26
31
  import {
27
32
  measureIndent,
@@ -180,8 +185,10 @@ export function parseJourneyMap(
180
185
  }
181
186
  }
182
187
  } else {
183
- // Same-line form (pipes removed in 0.18.0): peel a trailing
184
- // `color: <token>` off the rest-of-line persona name.
188
+ // Same-line form (pipes removed in 0.18.0). Two ways to set color:
189
+ // 1. explicit `persona <name> color: <token>` (long form)
190
+ // 2. universal §1.5 trailing-token color (`persona <name> green`);
191
+ // capitalize the word (`Green`) to keep it as literal name text.
185
192
  const colorMatch = afterKeyword.match(/^(.+?)\s+color:\s*(\S+)$/i);
186
193
  if (colorMatch) {
187
194
  personaName = colorMatch[1]!.trim();
@@ -193,7 +200,19 @@ export function parseJourneyMap(
193
200
  palette
194
201
  ) ?? undefined;
195
202
  } else {
196
- personaName = afterKeyword;
203
+ const { label, colorName } = peelTrailingColorName(afterKeyword);
204
+ if (colorName) {
205
+ personaName = label;
206
+ personaColor =
207
+ resolveColorWithDiagnostic(
208
+ colorName,
209
+ lineNumber,
210
+ result.diagnostics,
211
+ palette
212
+ ) ?? undefined;
213
+ } else {
214
+ personaName = afterKeyword;
215
+ }
197
216
  }
198
217
  }
199
218
 
@@ -246,12 +265,12 @@ export function parseJourneyMap(
246
265
  if (tagBlockMatch.alias) {
247
266
  aliasMap.set(
248
267
  tagBlockMatch.alias.toLowerCase(),
249
- tagBlockMatch.name.toLowerCase()
268
+ tagAttrKey(tagBlockMatch.name)
250
269
  );
251
270
  }
252
271
  aliasMap.set(
253
- tagBlockMatch.name.toLowerCase(),
254
- tagBlockMatch.name.toLowerCase()
272
+ tagAttrKey(tagBlockMatch.name),
273
+ tagAttrKey(tagBlockMatch.name)
255
274
  );
256
275
  result.tagGroups.push(currentTagGroup);
257
276
  continue;
@@ -445,7 +464,7 @@ export function parseJourneyMap(
445
464
  // Build tag value sets
446
465
  for (const group of result.tagGroups) {
447
466
  const values = new Set(group.entries.map((e) => e.value.toLowerCase()));
448
- tagValueSets.set(group.name.toLowerCase(), values);
467
+ tagValueSets.set(tagAttrKey(group.name), values);
449
468
  }
450
469
 
451
470
  // Validate tag values on all steps
@@ -1,3 +1,4 @@
1
+ import { tagAttrKey } from '../utils/tag-groups';
1
2
  import * as d3 from 'd3-selection';
2
3
  import * as d3Shape from 'd3-shape';
3
4
  import type { PaletteColors } from '../palettes';
@@ -18,6 +19,7 @@ import type {
18
19
  JourneyMapStep,
19
20
  } from './types';
20
21
  import { renderInlineText } from '../utils/inline-markdown';
22
+ import { wrapNoteBody, NOTE_BULLET_INDENT } from '../utils/note-box';
21
23
  import { renderIntegratedLegend } from '../utils/legend-integration';
22
24
  import { resolveActiveTagGroup } from '../utils/tag-groups';
23
25
  import { ScaleContext } from '../utils/scaling';
@@ -194,9 +196,10 @@ export function renderJourneyMap(
194
196
  const textAreaWidth = panelWidth - silhouetteZone - CARD_PADDING_X;
195
197
  const descLineH = 14;
196
198
 
197
- // Wrap description text into lines
199
+ // Wrap description text into lines — bullet-aware (`- `/`* ` → hanging
200
+ // "•") + inline markdown, matching how note boxes render rich bodies.
198
201
  const descLines = parsed.persona.description
199
- ? wrapText(parsed.persona.description, textAreaWidth, FONT_SIZE_META)
202
+ ? wrapNoteBody(parsed.persona.description, textAreaWidth, FONT_SIZE_META)
200
203
  : [];
201
204
  const descRowH =
202
205
  descLines.length > 0 ? descLines.length * descLineH + 8 : 0;
@@ -276,16 +279,30 @@ export function renderJourneyMap(
276
279
  .attr('fill', onPersonaText)
277
280
  .text(parsed.persona.name);
278
281
 
279
- // Description — wrapped lines below divider, with inline markdown
282
+ // Description — wrapped lines below divider, with inline markdown.
283
+ // Bullet first-lines get a "•" glyph at the left edge with the body
284
+ // hanging-indented; continuation lines align under the body.
280
285
  for (let li = 0; li < descLines.length; li++) {
286
+ // In-bounds by loop guard.
287
+ const line = descLines[li]!;
288
+ const indent = line.kind === 'plain' ? 0 : NOTE_BULLET_INDENT;
289
+ const lineY = panelY + titleRowH + descLineH * (li + 1);
290
+ if (line.kind === 'bullet-first') {
291
+ personaG
292
+ .append('text')
293
+ .attr('x', textX)
294
+ .attr('y', lineY)
295
+ .attr('font-size', FONT_SIZE_META)
296
+ .attr('fill', onPersonaText)
297
+ .text('•');
298
+ }
281
299
  const lineEl = personaG
282
300
  .append('text')
283
- .attr('x', textX)
284
- .attr('y', panelY + titleRowH + descLineH * (li + 1))
301
+ .attr('x', textX + indent)
302
+ .attr('y', lineY)
285
303
  .attr('font-size', FONT_SIZE_META)
286
304
  .attr('fill', onPersonaText);
287
- // In-bounds by loop guard.
288
- renderInlineText(lineEl, descLines[li]!, palette, FONT_SIZE_META);
305
+ renderInlineText(lineEl, line.text, palette, FONT_SIZE_META);
289
306
  }
290
307
 
291
308
  if (onNavigateToLine) {
@@ -346,7 +363,7 @@ export function renderJourneyMap(
346
363
  const isScore = groupName === 'Score';
347
364
  const attrName = isScore
348
365
  ? 'data-score'
349
- : `data-tag-${groupName.toLowerCase()}`;
366
+ : `data-tag-${tagAttrKey(groupName)}`;
350
367
 
351
368
  const matches = (el: Element) =>
352
369
  el.getAttribute(attrName) === entryValue;
@@ -1094,7 +1111,7 @@ function resolveStepColor(
1094
1111
  );
1095
1112
  if (!group) return undefined;
1096
1113
 
1097
- const tagValue = step.tags[group.name.toLowerCase()];
1114
+ const tagValue = step.tags[tagAttrKey(group.name)];
1098
1115
  if (!tagValue) return undefined;
1099
1116
 
1100
1117
  const entry = group.entries.find(
@@ -19,6 +19,7 @@ import {
19
19
  validateTagGroupNames,
20
20
  finalizeAutoTagColors,
21
21
  AUTO_TAG_COLOR_SENTINEL,
22
+ tagAttrKey,
22
23
  } from '../utils/tag-groups';
23
24
  import {
24
25
  measureIndent,
@@ -168,14 +169,14 @@ export function parseKanban(
168
169
  if (tagBlockMatch.alias) {
169
170
  metaAliasMap.set(
170
171
  normalizeName(tagBlockMatch.alias),
171
- tagBlockMatch.name.toLowerCase()
172
+ tagAttrKey(tagBlockMatch.name)
172
173
  );
173
174
  }
174
175
  // §1.4 dispatch: register the canonical tag name so `priority: X`
175
176
  // also dispatches as metadata (not just the optional `p: X` alias).
176
177
  metaAliasMap.set(
177
178
  normalizeName(tagBlockMatch.name),
178
- tagBlockMatch.name.toLowerCase()
179
+ tagAttrKey(tagBlockMatch.name)
179
180
  );
180
181
  result.tagGroups.push(currentTagGroup);
181
182
  continue;
@@ -419,7 +420,7 @@ export function parseKanban(
419
420
  // Build tag value sets for validation
420
421
  for (const group of result.tagGroups) {
421
422
  const values = new Set(group.entries.map((e) => e.value.toLowerCase()));
422
- tagValueSets.set(group.name.toLowerCase(), values);
423
+ tagValueSets.set(tagAttrKey(group.name), values);
423
424
  }
424
425
 
425
426
  // Validate WIP limits
@@ -2,6 +2,7 @@
2
2
  // Kanban Board SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import { FONT_FAMILY } from '../fonts';
7
8
  import type { PaletteColors } from '../palettes';
@@ -82,7 +83,7 @@ function resolveCardTagMeta(
82
83
  const meta: { label: string; value: string; color?: string }[] = [];
83
84
  for (const group of tagGroups) {
84
85
  if (hiddenMetaGroups?.includes(group.name.toLowerCase())) continue;
85
- const tagValue = card.tags[group.name.toLowerCase()];
86
+ const tagValue = card.tags[tagAttrKey(group.name)];
86
87
  const value = tagValue ?? group.defaultValue;
87
88
  if (!value) continue;
88
89
  const entry = group.entries.find(
@@ -107,7 +108,7 @@ function resolveCardTagColor(
107
108
  (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
108
109
  );
109
110
  if (!group) return card.color;
110
- const tagValue = card.tags[group.name.toLowerCase()];
111
+ const tagValue = card.tags[tagAttrKey(group.name)];
111
112
  const value = tagValue ?? group.defaultValue;
112
113
  if (!value) return undefined;
113
114
  const entry = group.entries.find(
@@ -639,7 +640,7 @@ export function renderKanban(
639
640
  .attr('data-line-number', card.lineNumber);
640
641
 
641
642
  if (activeTagGroup) {
642
- const tagKey = activeTagGroup.toLowerCase();
643
+ const tagKey = tagAttrKey(activeTagGroup);
643
644
  const tagValue = card.tags[tagKey];
644
645
  const group = parsed.tagGroups.find(
645
646
  (tg) => tg.name.toLowerCase() === tagKey
@@ -806,7 +807,7 @@ function bucketCardsBySwimlane(
806
807
  columns: KanbanColumn[],
807
808
  swimlaneGroup: KanbanTagGroup
808
809
  ): SwimlaneBucket[] {
809
- const tagKey = swimlaneGroup.name.toLowerCase();
810
+ const tagKey = tagAttrKey(swimlaneGroup.name);
810
811
  const buckets = new Map<string, SwimlaneBucket>();
811
812
 
812
813
  // Seed lanes from declaration order
@@ -1369,7 +1370,7 @@ function renderSwimlaneCard(
1369
1370
  .attr('data-line-number', card.lineNumber);
1370
1371
 
1371
1372
  if (activeTagGroup) {
1372
- const tagKey = activeTagGroup.toLowerCase();
1373
+ const tagKey = tagAttrKey(activeTagGroup);
1373
1374
  const group = tagGroups.find((tg) => tg.name.toLowerCase() === tagKey);
1374
1375
  const value = card.tags[tagKey] ?? group?.defaultValue;
1375
1376
  if (value) {
@@ -151,6 +151,122 @@ export function completeMapPlaces(
151
151
  return [...cityItems, ...airportItems].slice(0, limit);
152
152
  }
153
153
 
154
+ /** A POI search hit — a city or airport the resolver can resolve, with the
155
+ * exact `token` to paste into DGMO (a `poi`/route endpoint). Powers the CLI
156
+ * `dgmo map search` command and the MCP `lookup_map_location` tool, so authors
157
+ * (and AI agents) can DISCOVER valid place tokens instead of guessing — e.g.
158
+ * learn that "New York" must be `New York City` and that `JFK` is bundled. */
159
+ export interface MapLocationMatch {
160
+ /** Canonical display name (cities) or full airport name. */
161
+ readonly name: string;
162
+ /** Exact text to use in DGMO. Cities: the name, ISO-qualified when ambiguous
163
+ * (`Portland US-OR`). Airports: the upper-case IATA code (`JFK`). */
164
+ readonly token: string;
165
+ readonly kind: 'city' | 'airport';
166
+ readonly iso: string;
167
+ readonly sub?: string;
168
+ /** City population (0 for airports). */
169
+ readonly pop: number;
170
+ /** Human detail, e.g. `US-OR · 652,503` or `Airport · John F Kennedy Intl`. */
171
+ readonly detail: string;
172
+ }
173
+
174
+ /**
175
+ * Substring-search cities + airports for a discovery surface (CLI / MCP), so an
176
+ * author can find the exact token the resolver expects. Unlike
177
+ * {@link completeMapPlaces} (prefix-only, editor type-ahead), this matches
178
+ * anywhere in a city name OR an airport code/name, so "york" finds
179
+ * `New York City` and "kennedy" finds `JFK`.
180
+ *
181
+ * Ranking: exact-name (or exact-IATA) → prefix → substring; within a tier,
182
+ * cities by population desc (deterministic index tie-break), cities before
183
+ * airports. Pure + deterministic. Empty query → `[]`.
184
+ */
185
+ export function searchMapLocations(
186
+ query: string,
187
+ gazetteer: Gazetteer,
188
+ opts?: { readonly limit?: number; readonly airports?: AirportData }
189
+ ): MapLocationMatch[] {
190
+ const q = fold(query);
191
+ if (!q) return [];
192
+ const limit = opts?.limit ?? 20;
193
+
194
+ // rank: 0 exact, 1 prefix, 2 substring; -1 = no match.
195
+ const rankOf = (hay: string): number =>
196
+ hay === q ? 0 : hay.startsWith(q) ? 1 : hay.includes(q) ? 2 : -1;
197
+
198
+ // Cities — match on folded display name (via byName keys) + aliases.
199
+ const cityRank = new Map<number, number>(); // city index → best rank
200
+ const consider = (idx: number, rank: number): void => {
201
+ if (rank < 0) return;
202
+ const prev = cityRank.get(idx);
203
+ if (prev === undefined || rank < prev) cityRank.set(idx, rank);
204
+ };
205
+ for (const [key, list] of Object.entries(gazetteer.byName)) {
206
+ const r = rankOf(key);
207
+ if (r >= 0) for (const i of list) consider(i, r);
208
+ }
209
+ for (const [key, idx] of Object.entries(gazetteer.alt)) {
210
+ consider(idx, rankOf(key));
211
+ }
212
+
213
+ const cityItems: Array<MapLocationMatch & { _rank: number }> = [...cityRank]
214
+ .filter(([i]) => gazetteer.cities[i] !== undefined)
215
+ .map(([i, rank]) => {
216
+ const c = gazetteer.cities[i]!;
217
+ const [, , iso, pop, name, sub] = c;
218
+ const ambiguous = (gazetteer.byName[fold(name)]?.length ?? 0) > 1;
219
+ const qualifier = sub ?? iso;
220
+ return {
221
+ name,
222
+ token: ambiguous ? `${name} ${qualifier}` : name,
223
+ kind: 'city' as const,
224
+ iso,
225
+ ...(sub !== undefined && { sub }),
226
+ pop,
227
+ detail: `${qualifier} · ${groupThousands(pop)}`,
228
+ _rank: rank,
229
+ };
230
+ });
231
+
232
+ // Airports — match on folded IATA code OR folded airport name.
233
+ const airports = opts?.airports;
234
+ const airportItems: Array<MapLocationMatch & { _rank: number }> = [];
235
+ if (airports?.airportIata) {
236
+ for (const [code, idx] of Object.entries(airports.airportIata)) {
237
+ const a = airports.airports[idx];
238
+ if (!a) continue;
239
+ const codeRank = rankOf(code);
240
+ const nameRank = rankOf(fold(a[4]));
241
+ const rank = Math.min(
242
+ codeRank < 0 ? 99 : codeRank,
243
+ nameRank < 0 ? 99 : nameRank
244
+ );
245
+ if (rank > 2) continue;
246
+ airportItems.push({
247
+ name: a[4],
248
+ token: code.toUpperCase(),
249
+ kind: 'airport',
250
+ iso: a[2],
251
+ pop: 0,
252
+ detail: `Airport · ${a[4]}`,
253
+ _rank: rank,
254
+ });
255
+ }
256
+ }
257
+
258
+ return [...cityItems, ...airportItems]
259
+ .sort(
260
+ (x, y) =>
261
+ x._rank - y._rank || // best match tier first
262
+ (x.kind === y.kind ? 0 : x.kind === 'city' ? -1 : 1) || // city before airport
263
+ y.pop - x.pop || // populous city first
264
+ (x.token < y.token ? -1 : x.token > y.token ? 1 : 0)
265
+ )
266
+ .slice(0, limit)
267
+ .map(({ _rank, ...rest }) => rest);
268
+ }
269
+
154
270
  export interface MapRegionCompletion {
155
271
  /** Display name = insert text (the resolver disambiguates cross-layer
156
272
  * collisions like Georgia by map scope, §24B.8). */
package/src/map/layout.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // POI radii + edge widths, and places labels with per-cluster collision
6
6
  // escalation. The SVG emission is renderer.ts (it only draws what we compute).
7
7
  // See spec section 24B.3-.7/.11 + the tech-spec Adversarial Review Resolutions AR1-AR9.
8
+ import { tagAttrKey } from '../utils/tag-groups';
8
9
  import {
9
10
  geoPath,
10
11
  geoNaturalEarth1,
@@ -1582,7 +1583,7 @@ export function layoutMap(
1582
1583
  (g) => g.name.toLowerCase() === groupName.toLowerCase()
1583
1584
  );
1584
1585
  if (!group) return null;
1585
- const val = tags[group.name.toLowerCase()] ?? group.defaultValue;
1586
+ const val = tags[tagAttrKey(group.name)] ?? group.defaultValue;
1586
1587
  if (!val) return null;
1587
1588
  const entry = group.entries.find(
1588
1589
  (e) => e.value.toLowerCase() === val.toLowerCase()
@@ -2656,7 +2657,7 @@ export function layoutMap(
2656
2657
  if (directHex)
2657
2658
  return { fill: directHex, stroke: mix(directHex, palette.text, 18) };
2658
2659
  for (const group of resolved.tagGroups) {
2659
- const val = p.tags[group.name.toLowerCase()];
2660
+ const val = p.tags[tagAttrKey(group.name)];
2660
2661
  if (!val) continue;
2661
2662
  const entry = group.entries.find(
2662
2663
  (e) => e.value.toLowerCase() === val.toLowerCase()
@@ -2678,7 +2679,7 @@ export function layoutMap(
2678
2679
  // or null → caller falls back to the neutral connector mix.
2679
2680
  const lineColor = (tags: Readonly<Record<string, string>>): string | null => {
2680
2681
  for (const group of resolved.tagGroups) {
2681
- const val = tags[group.name.toLowerCase()];
2682
+ const val = tags[tagAttrKey(group.name)];
2682
2683
  if (!val) continue;
2683
2684
  const entry = group.entries.find(
2684
2685
  (e) => e.value.toLowerCase() === val.toLowerCase()
package/src/map/parser.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  validateTagGroupNames,
26
26
  finalizeAutoTagColors,
27
27
  AUTO_TAG_COLOR_SENTINEL,
28
+ tagAttrKey,
28
29
  } from '../utils/tag-groups';
29
30
  import { parseInArrowLabel } from '../utils/arrows';
30
31
  import type {
@@ -157,7 +158,7 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
157
158
  // Declared tag-group names (lowercased) — the keys `partitionMeta` treats as
158
159
  // tag values (resolved from aliases). Recomputed as groups are declared.
159
160
  const tagGroupNames = (): ReadonlySet<string> =>
160
- new Set(tagGroups.map((g) => g.name.toLowerCase()));
161
+ new Set(tagGroups.map((g) => tagAttrKey(g.name)));
161
162
 
162
163
  const closeBlocks = (): void => {
163
164
  open.tag = null;
@@ -378,7 +379,7 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
378
379
  entries: [],
379
380
  lineNumber: line,
380
381
  };
381
- if (m.alias) aliasMap.set(m.alias.toLowerCase(), m.name.toLowerCase());
382
+ if (m.alias) aliasMap.set(m.alias.toLowerCase(), tagAttrKey(m.name));
382
383
  tagGroups.push(group);
383
384
  open.tag = group;
384
385
  // Inline form: `tag Market as m HQ indigo, Region teal` (R4).
@@ -19,6 +19,7 @@ import {
19
19
  finalizeAutoTagColors,
20
20
  cascadeTagMetadata,
21
21
  AUTO_TAG_COLOR_SENTINEL,
22
+ tagAttrKey,
22
23
  } from '../utils/tag-groups';
23
24
  import {
24
25
  measureIndent,
@@ -167,12 +168,12 @@ export function parseMindmap(
167
168
  if (tagBlockMatch.alias) {
168
169
  aliasMap.set(
169
170
  tagBlockMatch.alias.toLowerCase(),
170
- tagBlockMatch.name.toLowerCase()
171
+ tagAttrKey(tagBlockMatch.name)
171
172
  );
172
173
  }
173
174
  aliasMap.set(
174
- tagBlockMatch.name.toLowerCase(),
175
- tagBlockMatch.name.toLowerCase()
175
+ tagAttrKey(tagBlockMatch.name),
176
+ tagAttrKey(tagBlockMatch.name)
176
177
  );
177
178
  result.tagGroups.push(currentTagGroup);
178
179
  continue;
@@ -2,6 +2,7 @@
2
2
  // Mindmap SVG Renderer
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import * as d3Selection from 'd3-selection';
6
7
  import { FONT_FAMILY } from '../fonts';
7
8
  import {
@@ -389,7 +390,7 @@ export function renderMindmap(
389
390
 
390
391
  // Expose active tag group value for legend-entry hover dimming
391
392
  if (activeTagGroup) {
392
- const tagKey = activeTagGroup.toLowerCase();
393
+ const tagKey = tagAttrKey(activeTagGroup);
393
394
  const metaValue = node.metadata[tagKey];
394
395
  if (metaValue) {
395
396
  nodeG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
package/src/org/layout.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Org Chart Tree Layout Engine (d3-hierarchy)
3
3
  // ============================================================
4
4
 
5
+ import { tagAttrKey } from '../utils/tag-groups';
5
6
  import { hierarchy, tree } from 'd3-hierarchy';
6
7
  import type { ParsedOrg, OrgNode } from './parser';
7
8
  import type { Writable } from '../utils/brand';
@@ -303,7 +304,7 @@ function computeLegendGroups(
303
304
  if (group.entries.length === 0) continue;
304
305
 
305
306
  // Filter entries to only values actually used by nodes (if provided)
306
- const usedValues = usedValuesByGroup?.get(group.name.toLowerCase());
307
+ const usedValues = usedValuesByGroup?.get(tagAttrKey(group.name));
307
308
  const visibleEntries = usedValues
308
309
  ? group.entries.filter((e) => usedValues.has(e.value.toLowerCase()))
309
310
  : group.entries;
@@ -1185,7 +1186,7 @@ export function layoutOrg(
1185
1186
  // Collect which tag group values are actually used by nodes
1186
1187
  const usedValuesByGroup = new Map<string, Set<string>>();
1187
1188
  for (const group of parsed.tagGroups) {
1188
- const key = group.name.toLowerCase();
1189
+ const key = tagAttrKey(group.name);
1189
1190
  const used = new Set<string>();
1190
1191
  const walk = (node: OrgNode) => {
1191
1192
  if (!node.isContainer && node.metadata[key]) {
package/src/org/parser.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  stripDefaultModifier,
21
21
  finalizeAutoTagColors,
22
22
  AUTO_TAG_COLOR_SENTINEL,
23
+ tagAttrKey,
23
24
  } from '../utils/tag-groups';
24
25
  import {
25
26
  measureIndent,
@@ -219,14 +220,14 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
219
220
  if (tagBlockMatch.alias) {
220
221
  metaAliasMap.set(
221
222
  normalizeName(tagBlockMatch.alias),
222
- tagBlockMatch.name.toLowerCase()
223
+ tagAttrKey(tagBlockMatch.name)
223
224
  );
224
225
  }
225
226
  // §1.4 dispatch: register canonical name so `<name>:` triggers
226
227
  // the metadata cut even without an explicit `as <alias>`.
227
228
  metaAliasMap.set(
228
229
  normalizeName(tagBlockMatch.name),
229
- tagBlockMatch.name.toLowerCase()
230
+ tagAttrKey(tagBlockMatch.name)
230
231
  );
231
232
  result.tagGroups.push(currentTagGroup);
232
233
  continue;