@diagrammo/dgmo 0.8.22 → 0.8.25

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 (90) hide show
  1. package/.claude/commands/dgmo.md +60 -72
  2. package/dist/cli.cjs +123 -116
  3. package/dist/editor.cjs +3 -2
  4. package/dist/editor.cjs.map +1 -1
  5. package/dist/editor.js +3 -2
  6. package/dist/editor.js.map +1 -1
  7. package/dist/highlight.cjs +3 -2
  8. package/dist/highlight.cjs.map +1 -1
  9. package/dist/highlight.js +3 -2
  10. package/dist/highlight.js.map +1 -1
  11. package/dist/index.cjs +1649 -442
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +196 -23
  14. package/dist/index.d.ts +196 -23
  15. package/dist/index.js +1631 -440
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal.cjs +677 -0
  18. package/dist/internal.cjs.map +1 -0
  19. package/dist/internal.d.cts +267 -0
  20. package/dist/internal.d.ts +267 -0
  21. package/dist/internal.js +633 -0
  22. package/dist/internal.js.map +1 -0
  23. package/docs/guide/chart-area.md +17 -17
  24. package/docs/guide/chart-bar-stacked.md +12 -12
  25. package/docs/guide/chart-cycle.md +156 -0
  26. package/docs/guide/chart-doughnut.md +10 -10
  27. package/docs/guide/chart-funnel.md +9 -9
  28. package/docs/guide/chart-heatmap.md +10 -10
  29. package/docs/guide/chart-journey-map.md +179 -0
  30. package/docs/guide/chart-kanban.md +2 -0
  31. package/docs/guide/chart-line.md +19 -19
  32. package/docs/guide/chart-multi-line.md +16 -16
  33. package/docs/guide/chart-pie.md +11 -11
  34. package/docs/guide/chart-polar-area.md +10 -10
  35. package/docs/guide/chart-pyramid.md +111 -0
  36. package/docs/guide/chart-radar.md +9 -9
  37. package/docs/guide/chart-scatter.md +24 -27
  38. package/docs/guide/index.md +3 -3
  39. package/docs/guide/registry.json +5 -0
  40. package/docs/language-reference.md +108 -26
  41. package/fonts/Inter-Bold.ttf +0 -0
  42. package/fonts/Inter-Regular.ttf +0 -0
  43. package/fonts/LICENSE-Inter.txt +92 -0
  44. package/gallery/fixtures/bar-stacked.dgmo +12 -6
  45. package/gallery/fixtures/heatmap.dgmo +12 -6
  46. package/gallery/fixtures/multi-line.dgmo +11 -7
  47. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  48. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  49. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  50. package/gallery/fixtures/quadrant.dgmo +8 -8
  51. package/gallery/fixtures/scatter.dgmo +12 -12
  52. package/package.json +14 -2
  53. package/src/boxes-and-lines/parser.ts +13 -2
  54. package/src/boxes-and-lines/renderer.ts +22 -13
  55. package/src/chart-type-scoring.ts +162 -0
  56. package/src/chart-types.ts +437 -0
  57. package/src/cli.ts +152 -101
  58. package/src/completion.ts +9 -48
  59. package/src/cycle/layout.ts +19 -28
  60. package/src/cycle/renderer.ts +59 -32
  61. package/src/cycle/types.ts +21 -0
  62. package/src/d3.ts +30 -3
  63. package/src/dgmo-router.ts +98 -73
  64. package/src/echarts.ts +1 -1
  65. package/src/editor/keywords.ts +3 -2
  66. package/src/fonts.ts +3 -2
  67. package/src/gantt/parser.ts +5 -1
  68. package/src/index.ts +37 -3
  69. package/src/infra/parser.ts +3 -3
  70. package/src/internal.ts +20 -0
  71. package/src/journey-map/layout.ts +7 -3
  72. package/src/journey-map/parser.ts +5 -1
  73. package/src/journey-map/renderer.ts +112 -47
  74. package/src/kanban/parser.ts +5 -1
  75. package/src/org/collapse.ts +82 -4
  76. package/src/org/parser.ts +1 -1
  77. package/src/org/renderer.ts +221 -4
  78. package/src/pyramid/parser.ts +172 -0
  79. package/src/pyramid/renderer.ts +684 -0
  80. package/src/pyramid/types.ts +28 -0
  81. package/src/render.ts +2 -8
  82. package/src/sequence/parser.ts +64 -22
  83. package/src/sequence/participant-inference.ts +0 -1
  84. package/src/sequence/renderer.ts +97 -265
  85. package/src/sharing.ts +0 -1
  86. package/src/sitemap/parser.ts +1 -1
  87. package/src/tech-radar/interactive.ts +54 -0
  88. package/src/utils/parsing.ts +1 -0
  89. package/src/utils/tag-groups.ts +35 -5
  90. package/src/wireframe/parser.ts +3 -1
@@ -0,0 +1,92 @@
1
+ Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font creation
14
+ efforts of academic and linguistic communities, and to provide a free and
15
+ open framework in which fonts may be shared and improved in partnership
16
+ with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply
25
+ to any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software components as
36
+ distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to, deleting,
39
+ or substituting -- in part or in whole -- any of the components of the
40
+ Original Version, by changing formats or by porting the Font Software to a
41
+ new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION AND CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
49
+ redistribute, and sell modified and unmodified copies of the Font
50
+ Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components,
53
+ in Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the corresponding
64
+ Copyright Holder. This restriction only applies to the primary font name as
65
+ presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created
77
+ using the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -2,9 +2,15 @@ bar-stacked Support Tickets by Priority
2
2
  x-label Month
3
3
  y-label Tickets
4
4
 
5
- series Critical (red), High (orange), Medium (yellow), Low (green), Info (blue)
6
- January 8, 24, 45, 62, 31
7
- February 12, 19, 51, 58, 28
8
- March 6, 22, 38, 71, 35
9
- April 10, 28, 42, 65, 40
10
- May 5, 15, 48, 70, 33
5
+ series
6
+ Critical (red)
7
+ High (orange)
8
+ Medium (yellow)
9
+ Low (green)
10
+ Info (blue)
11
+
12
+ January 8 24 45 62 31
13
+ February 12 19 51 58 28
14
+ March 6 22 38 71 35
15
+ April 10 28 42 65 40
16
+ May 5 15 48 70 33
@@ -1,8 +1,14 @@
1
1
  heatmap Deploy Frequency by Day and Hour
2
2
 
3
- columns Mon, Tue, Wed, Thu, Fri
4
- 6 AM 1, 2, 0, 1, 0
5
- 9 AM 5, 8, 6, 7, 4
6
- 12 PM 3, 4, 5, 3, 2
7
- 3 PM 8, 12, 9, 10, 6
8
- 6 PM 2, 3, 1, 2, 1
3
+ columns
4
+ Mon
5
+ Tue
6
+ Wed
7
+ Thu
8
+ Fri
9
+
10
+ 6 AM 1 2 0 1 0
11
+ 9 AM 5 8 6 7 4
12
+ 12 PM 3 4 5 3 2
13
+ 3 PM 8 12 9 10 6
14
+ 6 PM 2 3 1 2 1
@@ -2,10 +2,14 @@ multi-line Quarterly Revenue vs Operating Cost
2
2
  x-label Quarter
3
3
  y-label Amount ($M)
4
4
 
5
- series Revenue (blue), Operating Cost (red), Net Profit (green)
6
- Q1 2023 4.2, 3.1, 1.1
7
- Q2 2023 4.8, 3.3, 1.5
8
- Q3 2023 5.1, 3.5, 1.6
9
- Q4 2023 5.9, 3.7, 2.2
10
- Q1 2024 6.3, 3.9, 2.4
11
- Q2 2024 7.1, 4.2, 2.9
5
+ series
6
+ Revenue (blue)
7
+ Operating Cost (red)
8
+ Net Profit (green)
9
+
10
+ Q1 2023 4.2 3.1 1.1
11
+ Q2 2023 4.8 3.3 1.5
12
+ Q3 2023 5.1 3.5 1.6
13
+ Q4 2023 5.9 3.7 2.2
14
+ Q1 2024 6.3 3.9 2.4
15
+ Q2 2024 7.1 4.2 2.9
@@ -0,0 +1,17 @@
1
+ pyramid The DIKW Pyramid
2
+
3
+ Wisdom | color: purple
4
+ Ethical judgment, acting with
5
+ insight — knowing *why*.
6
+
7
+ Knowledge | color: blue
8
+ Synthesized information —
9
+ knowing *how*.
10
+
11
+ Information | color: green
12
+ Processed data with context —
13
+ knowing *what*.
14
+
15
+ Data | color: yellow
16
+ Raw facts and measurements —
17
+ unprocessed signals.
@@ -0,0 +1,16 @@
1
+ pyramid Acquisition Funnel
2
+
3
+ inverted
4
+
5
+ Visitors | color: blue
6
+ Landed on the site from any channel
7
+ this month.
8
+
9
+ Signups | color: cyan
10
+ Provided an email and confirmed.
11
+
12
+ Activated | color: green
13
+ Completed the first meaningful action.
14
+
15
+ Paid | color: orange
16
+ Converted to a paid plan.
@@ -0,0 +1,5 @@
1
+ pyramid
2
+
3
+ Top
4
+ Middle
5
+ Bottom
@@ -8,11 +8,11 @@ top-right Major Projects
8
8
  bottom-left Fill-ins
9
9
  bottom-right Avoid
10
10
 
11
- Dark Mode (blue) 0.25, 0.85
12
- API v2 (red) 0.8, 0.9
13
- Fix Typos 0.1, 0.15
14
- SSO Integration 0.75, 0.7
15
- Export CSV 0.3, 0.6
16
- Refactor Auth 0.85, 0.3
17
- Add Tooltips 0.15, 0.45
18
- Mobile App 0.9, 0.95
11
+ Dark Mode (blue) 0.25 0.85
12
+ API v2 (red) 0.8 0.9
13
+ Fix Typos 0.1 0.15
14
+ SSO Integration 0.75 0.7
15
+ Export CSV 0.3 0.6
16
+ Refactor Auth 0.85 0.3
17
+ Add Tooltips 0.15 0.45
18
+ Mobile App 0.9 0.95
@@ -3,19 +3,19 @@ x-label Funding ($M)
3
3
  y-label Annual Revenue ($M)
4
4
 
5
5
  [SaaS](blue)
6
- Acme Cloud 12, 8.5
7
- DataSync 5.2, 3.1
8
- CloudOps 25, 18.4
9
- PlatformX 8, 5.7
10
- NexGen 3.5, 1.2
6
+ Acme Cloud 12 8.5
7
+ DataSync 5.2 3.1
8
+ CloudOps 25 18.4
9
+ PlatformX 8 5.7
10
+ NexGen 3.5 1.2
11
11
 
12
12
  [Fintech](green)
13
- PayFlow 45, 32
14
- LendTech 18, 12.5
15
- CoinBase+ 60, 41
16
- QuickPay 9, 6.8
13
+ PayFlow 45 32
14
+ LendTech 18 12.5
15
+ CoinBase+ 60 41
16
+ QuickPay 9 6.8
17
17
 
18
18
  [HealthTech](red)
19
- MediScan 15, 7.2
20
- HealthAI 22, 14.1
21
- CareLink 7, 3.8
19
+ MediScan 15 7.2
20
+ HealthAI 22 14.1
21
+ CareLink 7 3.8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.8.22",
3
+ "version": "0.8.25",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,10 +40,22 @@
40
40
  "types": "./dist/highlight.d.cts",
41
41
  "default": "./dist/highlight.cjs"
42
42
  }
43
- }
43
+ },
44
+ "./internal": {
45
+ "import": {
46
+ "types": "./dist/internal.d.ts",
47
+ "default": "./dist/internal.js"
48
+ },
49
+ "require": {
50
+ "types": "./dist/internal.d.cts",
51
+ "default": "./dist/internal.cjs"
52
+ }
53
+ },
54
+ "./fonts/*": "./fonts/*"
44
55
  },
45
56
  "files": [
46
57
  "dist",
58
+ "fonts",
47
59
  "src",
48
60
  "docs",
49
61
  "gallery/fixtures",
@@ -97,6 +97,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
97
97
  const groupLabels = new Set<string>();
98
98
  let lastNodeLabel: string | null = null;
99
99
  let lastSourceIsGroup = false;
100
+ let lastNodeIndent = 0;
100
101
 
101
102
  // Description collection state
102
103
  let descState: {
@@ -481,9 +482,14 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
481
482
  // Indented shorthand: `-> Target` or `-label-> Target`
482
483
  if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
483
484
  // If the edge is at group-child indent level, use the containing group
485
+ // UNLESS lastNodeLabel is a plain node (not a group) — then the edge
486
+ // is indented under that node and should source from it.
484
487
  const gs = currentGroupState();
485
488
  const inGroup = gs && indent > gs.indent;
486
- if (inGroup) {
489
+ // Edge is deeper than the last node → indented under that node, use it
490
+ const indentedUnderNode =
491
+ lastNodeLabel && !lastSourceIsGroup && indent > lastNodeIndent;
492
+ if (inGroup && !indentedUnderNode) {
487
493
  const sourcePrefix = `[${gs.group.label}]`;
488
494
  edgeText = `${sourcePrefix} ${trimmed}`;
489
495
  } else if (lastNodeLabel) {
@@ -530,6 +536,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
530
536
  }
531
537
  lastNodeLabel = node.label;
532
538
  lastSourceIsGroup = false;
539
+ lastNodeIndent = indent;
533
540
 
534
541
  const gs = currentGroupState();
535
542
  const isGroupChild = gs && indent > gs.indent;
@@ -616,7 +623,11 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
616
623
  if (result.tagGroups.length > 0) {
617
624
  injectDefaultTagMetadata(result.nodes, result.tagGroups);
618
625
  validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
619
- validateTagGroupNames(result.tagGroups, pushWarning);
626
+ validateTagGroupNames(result.tagGroups, pushWarning, (line, msg) => {
627
+ const diag = makeDgmoError(line, msg);
628
+ result.diagnostics.push(diag);
629
+ if (!result.error) result.error = diag.message;
630
+ });
620
631
  }
621
632
 
622
633
  return result;
@@ -488,19 +488,28 @@ export function renderBoxesAndLines(
488
488
  .attr('clip-path', `url(#${clipId})`)
489
489
  .attr('class', 'bl-collapse-bar');
490
490
 
491
- // Label centered vertically
492
- groupG
493
- .append('text')
494
- .attr('class', 'bl-group-label')
495
- .attr('x', group.x)
496
- .attr('y', group.y)
497
- .attr('text-anchor', 'middle')
498
- .attr('dominant-baseline', 'central')
499
- .attr('font-family', FONT_FAMILY)
500
- .attr('font-size', GROUP_LABEL_FONT_SIZE)
501
- .attr('font-weight', '600')
502
- .attr('fill', palette.text)
503
- .text(group.label);
491
+ // Label centered vertically — wrap like regular nodes
492
+ const maxLabelLines = Math.max(
493
+ 2,
494
+ Math.floor((group.height - 16) / (MIN_NODE_FONT_SIZE * 1.3))
495
+ );
496
+ const fitted = fitLabelToHeader(group.label, group.width, maxLabelLines);
497
+ const lineH = fitted.fontSize * 1.3;
498
+ const totalH = fitted.lines.length * lineH;
499
+ for (let li = 0; li < fitted.lines.length; li++) {
500
+ groupG
501
+ .append('text')
502
+ .attr('class', 'bl-group-label')
503
+ .attr('x', group.x)
504
+ .attr('y', group.y - totalH / 2 + lineH / 2 + li * lineH)
505
+ .attr('text-anchor', 'middle')
506
+ .attr('dominant-baseline', 'central')
507
+ .attr('font-family', FONT_FAMILY)
508
+ .attr('font-size', fitted.fontSize)
509
+ .attr('font-weight', '600')
510
+ .attr('fill', palette.text)
511
+ .text(fitted.lines[li]);
512
+ }
504
513
  } else {
505
514
  // Expanded: background container with label
506
515
  groupG
@@ -0,0 +1,162 @@
1
+ // ============================================================
2
+ // Chart-type scoring (pluggability seam)
3
+ // ============================================================
4
+ //
5
+ // This module is the designated pluggability point for chart-type selection.
6
+ // The input/output shape (prompt string → ranked candidates + confidence) is
7
+ // stable; the internals can later swap to embedding-based similarity, a local
8
+ // tiny LM, or any other approach without changing the MCP tool surface or
9
+ // the `chartTypes` data model.
10
+
11
+ import { chartTypes, type ChartTypeMeta } from './chart-types';
12
+
13
+ const TYPOGRAPHIC_REPLACEMENTS: [RegExp, string][] = [
14
+ [/[‘’]/g, "'"], // curly single quotes
15
+ [/[“”]/g, '"'], // curly double quotes
16
+ [/[–—]/g, '-'], // en/em dash
17
+ [/×/g, 'x'], // unicode multiplication → ASCII (for "2×2" vs "2x2")
18
+ ];
19
+
20
+ /** Normalize a string to lowercase ASCII-ish tokens for matching. */
21
+ export function normalize(s: string): string[] {
22
+ let out = s.normalize('NFKD').toLowerCase();
23
+ for (const [re, repl] of TYPOGRAPHIC_REPLACEMENTS)
24
+ out = out.replace(re, repl);
25
+ return out.split(/[^a-z0-9]+/).filter(Boolean);
26
+ }
27
+
28
+ /**
29
+ * True if `triggerTokens` appears as a contiguous slice of `promptTokens`.
30
+ * Token-based (not substring) — prevents "scatter plot" matching "scattered
31
+ * the plot", "ER diagram" matching "water diagram", and similar traps.
32
+ */
33
+ export function matchesContiguously(
34
+ promptTokens: readonly string[],
35
+ triggerTokens: readonly string[]
36
+ ): boolean {
37
+ if (triggerTokens.length === 0 || triggerTokens.length > promptTokens.length)
38
+ return false;
39
+ outer: for (let i = 0; i <= promptTokens.length - triggerTokens.length; i++) {
40
+ for (let j = 0; j < triggerTokens.length; j++) {
41
+ if (promptTokens[i + j] !== triggerTokens[j]) continue outer;
42
+ }
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ export interface ChartTypeScore {
49
+ readonly type: ChartTypeMeta;
50
+ readonly score: number;
51
+ readonly matched: string[];
52
+ }
53
+
54
+ /**
55
+ * Score a single chart type against a prompt.
56
+ *
57
+ * Primary signal: contiguous trigger-phrase matches weighted by token count
58
+ * (longer phrases beat shorter ones). Secondary signal: description-word
59
+ * overlap at 0.25× weight — a tiebreak-only hint that rescues prompts which
60
+ * miss every trigger but touch description vocabulary. Triggers always win
61
+ * over descriptions because any trigger match is ≥1.0 and descriptions
62
+ * contribute ≤0.25 per token.
63
+ */
64
+ export function scoreChartType(
65
+ prompt: string,
66
+ type: ChartTypeMeta
67
+ ): { score: number; matched: string[] } {
68
+ const promptTokens = normalize(prompt);
69
+ const matched: string[] = [];
70
+ let score = 0;
71
+
72
+ for (const trigger of type.triggers) {
73
+ const triggerTokens = normalize(trigger);
74
+ if (matchesContiguously(promptTokens, triggerTokens)) {
75
+ matched.push(trigger);
76
+ score += triggerTokens.length;
77
+ }
78
+ }
79
+
80
+ const descTokens = new Set(normalize(type.description));
81
+ let descHits = 0;
82
+ for (const t of promptTokens) if (descTokens.has(t)) descHits++;
83
+ score += descHits * 0.25;
84
+
85
+ return { score, matched };
86
+ }
87
+
88
+ /**
89
+ * Minimum trigger-based score for a confident match. A result below this
90
+ * floor means no actual trigger fired — only description-rescue tokens
91
+ * contributed — so the caller should drop to the fallback list instead of
92
+ * returning a confident-looking wrong answer.
93
+ *
94
+ * 1.0 is the weight of a single-token trigger. Anything less came entirely
95
+ * from 0.25× description hits.
96
+ */
97
+ export const MIN_PRIMARY_SCORE = 1.0;
98
+
99
+ /**
100
+ * Minimum absolute score gap required before calling a match
101
+ * non-ambiguous. 0.5 ≈ two description-rescue tokens' worth, or half a
102
+ * trigger-token difference. Below this, the cliff between "medium" and
103
+ * "ambiguous" is effectively noise.
104
+ */
105
+ export const AMBIGUITY_THRESHOLD = 0.5;
106
+
107
+ export type Confidence = 'high' | 'medium' | 'ambiguous';
108
+
109
+ /**
110
+ * Confidence from the top two scores. Rules:
111
+ * 1. top < MIN_PRIMARY_SCORE → ambiguous (no real trigger matched)
112
+ * 2. second === 0 → high (nothing competes)
113
+ * 3. top ≥ 2 × second → high (top dominates)
114
+ * 4. top − second < AMBIGUITY_THRESHOLD → ambiguous (gap is noise)
115
+ * 5. otherwise → medium
116
+ */
117
+ export function confidence(top: number, second: number): Confidence {
118
+ if (top < MIN_PRIMARY_SCORE) return 'ambiguous';
119
+ if (second === 0) return 'high';
120
+ if (top >= second * 2) return 'high';
121
+ if (top - second < AMBIGUITY_THRESHOLD) return 'ambiguous';
122
+ return 'medium';
123
+ }
124
+
125
+ export interface SuggestionResult {
126
+ readonly ranked: readonly ChartTypeScore[];
127
+ readonly fallback: readonly ChartTypeMeta[];
128
+ readonly confidence: Confidence;
129
+ readonly fellBack: boolean;
130
+ }
131
+
132
+ /**
133
+ * Score every chart type against `prompt` and return a ranked suggestion
134
+ * bundle. Types with score 0 are filtered out. When the top score is below
135
+ * `MIN_PRIMARY_SCORE` (no real trigger fired), the caller should present
136
+ * the fallback list — `fellBack` is set to true in that case.
137
+ *
138
+ * Array order is preserved: scoring iterates `chartTypes` in source order
139
+ * and `.sort` is stable in V8, so ties go to the earlier entry — specialized
140
+ * types beat generic catch-alls by construction.
141
+ */
142
+ export function suggestChartTypes(prompt: string): SuggestionResult {
143
+ const scored: ChartTypeScore[] = [];
144
+ for (const type of chartTypes) {
145
+ const { score, matched } = scoreChartType(prompt, type);
146
+ if (score > 0) scored.push({ type, score, matched });
147
+ }
148
+ scored.sort((a, b) => b.score - a.score);
149
+
150
+ const fallback = chartTypes.filter((c) => c.fallback);
151
+
152
+ const topScore = scored[0]?.score ?? 0;
153
+ const secondScore = scored[1]?.score ?? 0;
154
+ const fellBack = topScore < MIN_PRIMARY_SCORE;
155
+
156
+ return {
157
+ ranked: scored,
158
+ fallback,
159
+ confidence: confidence(topScore, secondScore),
160
+ fellBack,
161
+ };
162
+ }