@diagrammo/dgmo 0.8.21 → 0.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +145 -93
- package/dist/editor.cjs +20 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +20 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +15 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +15 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +20843 -14937
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -17
- package/dist/index.d.ts +426 -17
- package/dist/index.js +20795 -14912
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +380 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +179 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +337 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +6 -0
- package/docs/language-reference.md +177 -6
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +11 -1
- package/src/boxes-and-lines/layout.ts +309 -33
- package/src/boxes-and-lines/parser.ts +86 -10
- package/src/boxes-and-lines/renderer.ts +250 -91
- package/src/boxes-and-lines/types.ts +1 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/cli.ts +5 -35
- package/src/completion.ts +233 -41
- package/src/cycle/layout.ts +723 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +566 -0
- package/src/cycle/types.ts +98 -0
- package/src/d3.ts +107 -8
- package/src/dgmo-router.ts +82 -3
- package/src/echarts.ts +8 -5
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +17 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/state-parser.ts +5 -10
- package/src/index.ts +63 -2
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +32 -8
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/internal.ts +16 -0
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1521 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +31 -15
- package/src/mindmap/parser.ts +12 -18
- package/src/mindmap/renderer.ts +14 -13
- package/src/mindmap/text-wrap.ts +22 -12
- package/src/mindmap/types.ts +2 -2
- package/src/org/collapse.ts +81 -0
- package/src/org/parser.ts +2 -6
- package/src/org/renderer.ts +212 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +62 -20
- package/src/sequence/renderer.ts +146 -40
- package/src/sharing.ts +1 -0
- package/src/sitemap/layout.ts +21 -6
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1112 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +47 -7
- package/src/utils/tag-groups.ts +46 -60
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import * as d3Selection from 'd3-selection';
|
|
2
|
+
import { FONT_FAMILY } from '../fonts';
|
|
3
|
+
import { mix } from '../palettes/color-utils';
|
|
4
|
+
import type { PaletteColors } from '../palettes';
|
|
5
|
+
import type { D3ExportDimensions } from '../utils/d3-types';
|
|
6
|
+
import type { CompactViewState } from '../sharing';
|
|
7
|
+
import { parseInlineMarkdown } from '../utils/inline-markdown';
|
|
8
|
+
import type {
|
|
9
|
+
ParsedTechRadar,
|
|
10
|
+
QuadrantPosition,
|
|
11
|
+
TechRadarRenderOptions,
|
|
12
|
+
} from './types';
|
|
13
|
+
import {
|
|
14
|
+
computeRadarLayout,
|
|
15
|
+
getRadarGeometry,
|
|
16
|
+
getQuadrantArc,
|
|
17
|
+
POSITION_ORDER,
|
|
18
|
+
} from './layout';
|
|
19
|
+
import {
|
|
20
|
+
resolveQuadrantColor,
|
|
21
|
+
renderTrendIndicator,
|
|
22
|
+
DIM_OPACITY,
|
|
23
|
+
TREND_ITEMS,
|
|
24
|
+
} from './shared';
|
|
25
|
+
import { renderQuadrantFocus } from './interactive';
|
|
26
|
+
import { renderLegendD3 } from '../utils/legend-d3';
|
|
27
|
+
import { LEGEND_HEIGHT } from '../utils/legend-constants';
|
|
28
|
+
import type {
|
|
29
|
+
LegendConfig,
|
|
30
|
+
LegendState,
|
|
31
|
+
LegendCallbacks,
|
|
32
|
+
LegendPalette,
|
|
33
|
+
} from '../utils/legend-types';
|
|
34
|
+
|
|
35
|
+
// ============================================================
|
|
36
|
+
// Constants
|
|
37
|
+
// ============================================================
|
|
38
|
+
|
|
39
|
+
const BLIP_RADIUS = 12;
|
|
40
|
+
const BLIP_FONT_SIZE = 9;
|
|
41
|
+
const RING_LABEL_FONT_SIZE = 13;
|
|
42
|
+
const QUADRANT_LABEL_FONT_SIZE = 18;
|
|
43
|
+
const TITLE_FONT_SIZE = 18;
|
|
44
|
+
const LISTING_FONT_SIZE = 12;
|
|
45
|
+
const LISTING_HEADER_FONT_SIZE = 13;
|
|
46
|
+
const LISTING_TOP_MARGIN = 24;
|
|
47
|
+
const LISTING_COL_GAP = 16;
|
|
48
|
+
const LISTING_LINE_HEIGHT = 24;
|
|
49
|
+
|
|
50
|
+
// ============================================================
|
|
51
|
+
// SVG Init (local, matches d3.ts pattern)
|
|
52
|
+
// ============================================================
|
|
53
|
+
|
|
54
|
+
function initRadarSvg(
|
|
55
|
+
container: HTMLDivElement,
|
|
56
|
+
palette: PaletteColors,
|
|
57
|
+
exportDims?: D3ExportDimensions
|
|
58
|
+
): {
|
|
59
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
|
|
60
|
+
width: number;
|
|
61
|
+
height: number;
|
|
62
|
+
textColor: string;
|
|
63
|
+
mutedColor: string;
|
|
64
|
+
bgColor: string;
|
|
65
|
+
} | null {
|
|
66
|
+
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
67
|
+
const width = exportDims?.width ?? container.clientWidth;
|
|
68
|
+
const height = exportDims?.height ?? container.clientHeight;
|
|
69
|
+
if (width <= 0 || height <= 0) return null;
|
|
70
|
+
const textColor = palette.text;
|
|
71
|
+
const mutedColor = palette.border;
|
|
72
|
+
const bgColor = palette.bg;
|
|
73
|
+
const svg = d3Selection
|
|
74
|
+
.select(container)
|
|
75
|
+
.append('svg')
|
|
76
|
+
.attr('width', width)
|
|
77
|
+
.attr('height', height)
|
|
78
|
+
.style('background', bgColor);
|
|
79
|
+
return { svg, width, height, textColor, mutedColor, bgColor };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// Main Renderer
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
export function renderTechRadar(
|
|
87
|
+
container: HTMLDivElement,
|
|
88
|
+
parsed: ParsedTechRadar,
|
|
89
|
+
palette: PaletteColors,
|
|
90
|
+
isDark: boolean,
|
|
91
|
+
onClickItem?: (lineNumber: number) => void,
|
|
92
|
+
exportDims?: D3ExportDimensions,
|
|
93
|
+
viewState?: CompactViewState,
|
|
94
|
+
options?: TechRadarRenderOptions
|
|
95
|
+
): void {
|
|
96
|
+
if (parsed.quadrants.length === 0 || parsed.rings.length === 0) return;
|
|
97
|
+
|
|
98
|
+
// If a quadrant is focused, delegate to the interactive module
|
|
99
|
+
// (but NOT for export — always export the full radar with blip legend)
|
|
100
|
+
if (viewState?.rq && !exportDims) {
|
|
101
|
+
renderQuadrantFocus(
|
|
102
|
+
container,
|
|
103
|
+
parsed,
|
|
104
|
+
viewState.rq as QuadrantPosition,
|
|
105
|
+
palette,
|
|
106
|
+
isDark,
|
|
107
|
+
onClickItem,
|
|
108
|
+
exportDims,
|
|
109
|
+
options
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Determine if listing is visible — always show for export (blip legend is essential)
|
|
115
|
+
const showListing = exportDims ? true : (options?.showListing ?? false);
|
|
116
|
+
const listingHeight = showListing ? estimateListingHeight(parsed) : 0;
|
|
117
|
+
|
|
118
|
+
const init = initRadarSvg(container, palette, exportDims);
|
|
119
|
+
if (!init) return;
|
|
120
|
+
const { svg, width, height, textColor, mutedColor } = init;
|
|
121
|
+
|
|
122
|
+
const radarHeight = Math.max(
|
|
123
|
+
200,
|
|
124
|
+
height - listingHeight - (showListing ? LISTING_TOP_MARGIN : 0)
|
|
125
|
+
);
|
|
126
|
+
const radarWidth = width;
|
|
127
|
+
|
|
128
|
+
// ── Title ──
|
|
129
|
+
const titleY = 24;
|
|
130
|
+
if (parsed.title) {
|
|
131
|
+
svg
|
|
132
|
+
.append('text')
|
|
133
|
+
.attr('x', radarWidth / 2)
|
|
134
|
+
.attr('y', titleY)
|
|
135
|
+
.attr('text-anchor', 'middle')
|
|
136
|
+
.attr('fill', textColor)
|
|
137
|
+
.attr('font-family', FONT_FAMILY)
|
|
138
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
139
|
+
.attr('font-weight', 'bold')
|
|
140
|
+
.text(parsed.title);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Legend controls (centered, standard legend system) ──
|
|
144
|
+
let legendReservedHeight = 0;
|
|
145
|
+
if (!exportDims && options?.onToggleListing) {
|
|
146
|
+
const legendY = parsed.title ? titleY + 8 : 4;
|
|
147
|
+
const legendG = svg
|
|
148
|
+
.append('g')
|
|
149
|
+
.attr('transform', `translate(0, ${legendY})`);
|
|
150
|
+
|
|
151
|
+
const legendConfig: LegendConfig = {
|
|
152
|
+
groups: [
|
|
153
|
+
{
|
|
154
|
+
name: 'Trends',
|
|
155
|
+
entries: TREND_ITEMS.map((item) => ({
|
|
156
|
+
value: item.label,
|
|
157
|
+
color: palette.textMuted,
|
|
158
|
+
})),
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
162
|
+
mode: 'fixed',
|
|
163
|
+
controlsGroup: {
|
|
164
|
+
toggles: [
|
|
165
|
+
{
|
|
166
|
+
id: 'blip-legend',
|
|
167
|
+
type: 'toggle',
|
|
168
|
+
label: 'Blip Legend',
|
|
169
|
+
active: showListing,
|
|
170
|
+
onToggle: (active: boolean) => options.onToggleListing!(active),
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const legendState: LegendState = {
|
|
176
|
+
activeGroup: options?.activeLegendGroup ?? null,
|
|
177
|
+
controlsExpanded: options.controlsExpanded,
|
|
178
|
+
};
|
|
179
|
+
const legendPalette: LegendPalette = {
|
|
180
|
+
text: palette.text,
|
|
181
|
+
textMuted: palette.textMuted,
|
|
182
|
+
bg: palette.bg,
|
|
183
|
+
surface: palette.surface,
|
|
184
|
+
primary: palette.primary,
|
|
185
|
+
};
|
|
186
|
+
const legendCallbacks: LegendCallbacks = {
|
|
187
|
+
onGroupToggle: options.onLegendGroupToggle,
|
|
188
|
+
onControlsExpand: options.onToggleControlsExpand,
|
|
189
|
+
onControlsToggle: (id, active) => {
|
|
190
|
+
if (id === 'blip-legend' && options.onToggleListing) {
|
|
191
|
+
options.onToggleListing(active);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
onEntryHover: (_groupName, entryValue) => {
|
|
195
|
+
if (!entryValue) {
|
|
196
|
+
// Hover out — restore all
|
|
197
|
+
svg
|
|
198
|
+
.selectAll<SVGElement, unknown>('[data-trend]')
|
|
199
|
+
.style('opacity', '1');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Map entry label back to trend value
|
|
203
|
+
const item = TREND_ITEMS.find((t) => t.label === entryValue);
|
|
204
|
+
if (!item) return;
|
|
205
|
+
const trendVal = item.trend ?? 'stable';
|
|
206
|
+
svg
|
|
207
|
+
.selectAll<SVGElement, unknown>('[data-trend]')
|
|
208
|
+
.style('opacity', function () {
|
|
209
|
+
return this.getAttribute('data-trend') === trendVal
|
|
210
|
+
? '1'
|
|
211
|
+
: String(DIM_OPACITY);
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
renderLegendD3(
|
|
217
|
+
legendG,
|
|
218
|
+
legendConfig,
|
|
219
|
+
legendState,
|
|
220
|
+
legendPalette,
|
|
221
|
+
isDark,
|
|
222
|
+
legendCallbacks,
|
|
223
|
+
width
|
|
224
|
+
);
|
|
225
|
+
legendReservedHeight = LEGEND_HEIGHT + 8;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const radarTop = (parsed.title ? titleY + 16 : 8) + legendReservedHeight;
|
|
229
|
+
const radarAreaHeight = radarHeight - radarTop;
|
|
230
|
+
const radarAreaWidth = radarWidth;
|
|
231
|
+
|
|
232
|
+
const { cx, cy, maxRadius, ringBandWidth } = getRadarGeometry(
|
|
233
|
+
radarAreaWidth,
|
|
234
|
+
radarAreaHeight,
|
|
235
|
+
parsed.rings.length
|
|
236
|
+
);
|
|
237
|
+
const offsetY = radarTop;
|
|
238
|
+
|
|
239
|
+
const radarGroup = svg
|
|
240
|
+
.append('g')
|
|
241
|
+
.attr('transform', `translate(0, ${offsetY})`);
|
|
242
|
+
|
|
243
|
+
// ── Ring segments (per quadrant arc slices for hover highlighting) ──
|
|
244
|
+
for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
|
|
245
|
+
const innerR = ri * ringBandWidth;
|
|
246
|
+
const outerR = (ri + 1) * ringBandWidth;
|
|
247
|
+
const fillColor =
|
|
248
|
+
ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
|
|
249
|
+
const ringName = parsed.rings[ri].name;
|
|
250
|
+
|
|
251
|
+
for (const quadrant of parsed.quadrants) {
|
|
252
|
+
const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
|
|
253
|
+
const path = buildArcSlicePath(
|
|
254
|
+
cx,
|
|
255
|
+
cy,
|
|
256
|
+
innerR,
|
|
257
|
+
outerR,
|
|
258
|
+
startAngle,
|
|
259
|
+
endAngle
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
radarGroup
|
|
263
|
+
.append('path')
|
|
264
|
+
.attr('d', path)
|
|
265
|
+
.attr('fill', fillColor)
|
|
266
|
+
.attr('stroke', mutedColor)
|
|
267
|
+
.attr('stroke-width', 0.5)
|
|
268
|
+
.attr('data-ring-segment', '')
|
|
269
|
+
.attr('data-quadrant', quadrant.position)
|
|
270
|
+
.attr('data-ring', ringName);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Quadrant divider lines ──
|
|
275
|
+
radarGroup
|
|
276
|
+
.append('line')
|
|
277
|
+
.attr('x1', cx - maxRadius)
|
|
278
|
+
.attr('y1', cy)
|
|
279
|
+
.attr('x2', cx + maxRadius)
|
|
280
|
+
.attr('y2', cy)
|
|
281
|
+
.attr('stroke', mutedColor)
|
|
282
|
+
.attr('stroke-width', 1);
|
|
283
|
+
radarGroup
|
|
284
|
+
.append('line')
|
|
285
|
+
.attr('x1', cx)
|
|
286
|
+
.attr('y1', cy - maxRadius)
|
|
287
|
+
.attr('x2', cx)
|
|
288
|
+
.attr('y2', cy + maxRadius)
|
|
289
|
+
.attr('stroke', mutedColor)
|
|
290
|
+
.attr('stroke-width', 1);
|
|
291
|
+
|
|
292
|
+
// ── Ring labels (along vertical axis, centered — avoids horizontal collision) ──
|
|
293
|
+
for (let ri = 0; ri < parsed.rings.length; ri++) {
|
|
294
|
+
const rCenter = (ri + 0.5) * ringBandWidth;
|
|
295
|
+
|
|
296
|
+
if (ri === 0) {
|
|
297
|
+
// Innermost ring: dead center
|
|
298
|
+
radarGroup
|
|
299
|
+
.append('text')
|
|
300
|
+
.attr('x', cx)
|
|
301
|
+
.attr('y', cy)
|
|
302
|
+
.attr('text-anchor', 'middle')
|
|
303
|
+
.attr('dominant-baseline', 'central')
|
|
304
|
+
.attr('fill', textColor)
|
|
305
|
+
.attr('font-family', FONT_FAMILY)
|
|
306
|
+
.attr('font-size', RING_LABEL_FONT_SIZE)
|
|
307
|
+
.attr('font-weight', '600')
|
|
308
|
+
.attr('opacity', 0.5)
|
|
309
|
+
.text(parsed.rings[ri].name);
|
|
310
|
+
} else {
|
|
311
|
+
// Above center
|
|
312
|
+
radarGroup
|
|
313
|
+
.append('text')
|
|
314
|
+
.attr('x', cx)
|
|
315
|
+
.attr('y', cy - rCenter)
|
|
316
|
+
.attr('text-anchor', 'middle')
|
|
317
|
+
.attr('dominant-baseline', 'central')
|
|
318
|
+
.attr('fill', textColor)
|
|
319
|
+
.attr('font-family', FONT_FAMILY)
|
|
320
|
+
.attr('font-size', RING_LABEL_FONT_SIZE)
|
|
321
|
+
.attr('font-weight', '600')
|
|
322
|
+
.attr('opacity', 0.5)
|
|
323
|
+
.text(parsed.rings[ri].name);
|
|
324
|
+
|
|
325
|
+
// Below center (mirrored)
|
|
326
|
+
radarGroup
|
|
327
|
+
.append('text')
|
|
328
|
+
.attr('x', cx)
|
|
329
|
+
.attr('y', cy + rCenter)
|
|
330
|
+
.attr('text-anchor', 'middle')
|
|
331
|
+
.attr('dominant-baseline', 'central')
|
|
332
|
+
.attr('fill', textColor)
|
|
333
|
+
.attr('font-family', FONT_FAMILY)
|
|
334
|
+
.attr('font-size', RING_LABEL_FONT_SIZE)
|
|
335
|
+
.attr('font-weight', '600')
|
|
336
|
+
.attr('opacity', 0.5)
|
|
337
|
+
.text(parsed.rings[ri].name);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Quadrant labels in corners ──
|
|
342
|
+
for (const quadrant of parsed.quadrants) {
|
|
343
|
+
const qColor = resolveQuadrantColor(
|
|
344
|
+
quadrant.position,
|
|
345
|
+
quadrant.color,
|
|
346
|
+
palette
|
|
347
|
+
);
|
|
348
|
+
const {
|
|
349
|
+
x: labelX,
|
|
350
|
+
y: labelY,
|
|
351
|
+
anchor,
|
|
352
|
+
} = getQuadrantLabelPosition(quadrant.position, cx, cy, maxRadius);
|
|
353
|
+
const labelGroup = radarGroup
|
|
354
|
+
.append('g')
|
|
355
|
+
.attr('data-line-number', quadrant.lineNumber)
|
|
356
|
+
.style('cursor', 'pointer');
|
|
357
|
+
|
|
358
|
+
renderQuadrantLabel(
|
|
359
|
+
labelGroup,
|
|
360
|
+
quadrant.name,
|
|
361
|
+
labelX,
|
|
362
|
+
labelY,
|
|
363
|
+
anchor,
|
|
364
|
+
qColor,
|
|
365
|
+
maxRadius * 0.9
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Interactive ring×quadrant hover hit areas (rendered before blips so blips sit on top) ──
|
|
370
|
+
if (!exportDims) {
|
|
371
|
+
renderRingHoverAreas(
|
|
372
|
+
radarGroup,
|
|
373
|
+
svg,
|
|
374
|
+
parsed,
|
|
375
|
+
palette,
|
|
376
|
+
cx,
|
|
377
|
+
cy,
|
|
378
|
+
ringBandWidth,
|
|
379
|
+
maxRadius
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Compute layout and render blips ──
|
|
384
|
+
const layoutPoints = computeRadarLayout(
|
|
385
|
+
parsed,
|
|
386
|
+
radarAreaWidth,
|
|
387
|
+
radarAreaHeight
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
// Rich popover for blip details
|
|
391
|
+
const popover = createBlipPopover(container, palette, isDark);
|
|
392
|
+
let pinnedLineNum: string | null = null;
|
|
393
|
+
|
|
394
|
+
function showBlipHighlight(
|
|
395
|
+
lineNum: string,
|
|
396
|
+
bx: number,
|
|
397
|
+
by: number,
|
|
398
|
+
blipGroup: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
399
|
+
) {
|
|
400
|
+
blipGroup.attr(
|
|
401
|
+
'transform',
|
|
402
|
+
`translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
|
|
403
|
+
);
|
|
404
|
+
svg
|
|
405
|
+
.selectAll<SVGElement, unknown>('[data-line-number]')
|
|
406
|
+
.style('opacity', function () {
|
|
407
|
+
return this.getAttribute('data-line-number') === lineNum
|
|
408
|
+
? '1'
|
|
409
|
+
: String(DIM_OPACITY);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function clearBlipHighlight() {
|
|
414
|
+
svg
|
|
415
|
+
.selectAll<SVGElement, unknown>('[data-line-number]')
|
|
416
|
+
.style('opacity', '1')
|
|
417
|
+
.attr('transform', null);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const point of layoutPoints) {
|
|
421
|
+
const quadrant = parsed.quadrants.find((q) =>
|
|
422
|
+
q.blips.includes(point.blip)
|
|
423
|
+
)!;
|
|
424
|
+
const qColor = resolveQuadrantColor(
|
|
425
|
+
quadrant.position,
|
|
426
|
+
quadrant.color,
|
|
427
|
+
palette
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const blipGroup = radarGroup
|
|
431
|
+
.append('g')
|
|
432
|
+
.attr('data-line-number', point.blip.lineNumber)
|
|
433
|
+
.attr('data-quadrant', quadrant.position)
|
|
434
|
+
.attr('data-ring', point.blip.ring)
|
|
435
|
+
.attr('data-trend', point.blip.trend ?? 'stable')
|
|
436
|
+
.style('cursor', 'pointer');
|
|
437
|
+
|
|
438
|
+
// Angle from blip toward radar center in SVG coords (Y-down)
|
|
439
|
+
const angleToCenter = Math.atan2(cy - point.y, cx - point.x);
|
|
440
|
+
|
|
441
|
+
// Trend indicator + circle
|
|
442
|
+
renderTrendIndicator(
|
|
443
|
+
blipGroup,
|
|
444
|
+
point.blip.trend,
|
|
445
|
+
qColor,
|
|
446
|
+
point.x,
|
|
447
|
+
point.y,
|
|
448
|
+
BLIP_RADIUS,
|
|
449
|
+
angleToCenter
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Blip number
|
|
453
|
+
blipGroup
|
|
454
|
+
.append('text')
|
|
455
|
+
.attr('x', point.x)
|
|
456
|
+
.attr('y', point.y + 3)
|
|
457
|
+
.attr('text-anchor', 'middle')
|
|
458
|
+
.attr('fill', isDark ? '#000' : '#fff')
|
|
459
|
+
.attr('font-family', FONT_FAMILY)
|
|
460
|
+
.attr('font-size', BLIP_FONT_SIZE)
|
|
461
|
+
.attr('font-weight', 'bold')
|
|
462
|
+
.text(point.blip.globalNumber);
|
|
463
|
+
|
|
464
|
+
// Hover: show rich popover + highlight
|
|
465
|
+
const lineNum = String(point.blip.lineNumber);
|
|
466
|
+
const bx = point.x;
|
|
467
|
+
const by = point.y;
|
|
468
|
+
blipGroup
|
|
469
|
+
.on('mouseenter', (event: MouseEvent) => {
|
|
470
|
+
if (pinnedLineNum) return; // don't interfere with pinned popover
|
|
471
|
+
showBlipPopover(popover, point.blip, qColor, palette, isDark, event);
|
|
472
|
+
showBlipHighlight(lineNum, bx, by, blipGroup);
|
|
473
|
+
})
|
|
474
|
+
.on('mousemove', (event: MouseEvent) => {
|
|
475
|
+
if (pinnedLineNum) return;
|
|
476
|
+
positionPopover(popover, event);
|
|
477
|
+
})
|
|
478
|
+
.on('mouseleave', () => {
|
|
479
|
+
if (pinnedLineNum) return;
|
|
480
|
+
hideBlipPopover(popover);
|
|
481
|
+
clearBlipHighlight();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Click: pin/unpin the popover (don't stopPropagation so the
|
|
485
|
+
// interactivity hook's document listener can fire for editor navigation)
|
|
486
|
+
blipGroup.on('click', (event: MouseEvent) => {
|
|
487
|
+
(event as MouseEvent & { _blipClick?: boolean })._blipClick = true;
|
|
488
|
+
if (pinnedLineNum === lineNum) {
|
|
489
|
+
// Unpin
|
|
490
|
+
pinnedLineNum = null;
|
|
491
|
+
hideBlipPopover(popover);
|
|
492
|
+
clearBlipHighlight();
|
|
493
|
+
} else {
|
|
494
|
+
// Pin this blip — enable pointer events so links are clickable
|
|
495
|
+
pinnedLineNum = lineNum;
|
|
496
|
+
showBlipPopover(popover, point.blip, qColor, palette, isDark, event);
|
|
497
|
+
popover.style.pointerEvents = 'auto';
|
|
498
|
+
showBlipHighlight(lineNum, bx, by, blipGroup);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Click on empty space clears pinned popover (ignore blip clicks)
|
|
504
|
+
svg.on('click', (event: MouseEvent) => {
|
|
505
|
+
if ((event as MouseEvent & { _blipClick?: boolean })._blipClick) return;
|
|
506
|
+
if (pinnedLineNum) {
|
|
507
|
+
pinnedLineNum = null;
|
|
508
|
+
hideBlipPopover(popover);
|
|
509
|
+
clearBlipHighlight();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ── Active line from editor cursor → show popover for that blip ──
|
|
514
|
+
if (options?.activeLine && !pinnedLineNum) {
|
|
515
|
+
const activeLn = options.activeLine;
|
|
516
|
+
// Find the blip that matches this line (or whose description contains this line)
|
|
517
|
+
for (const point of layoutPoints) {
|
|
518
|
+
const blip = point.blip;
|
|
519
|
+
const isOnBlip = blip.lineNumber === activeLn;
|
|
520
|
+
const isOnDesc =
|
|
521
|
+
blip.description.length > 0 &&
|
|
522
|
+
activeLn > blip.lineNumber &&
|
|
523
|
+
activeLn <= blip.lineNumber + blip.description.length;
|
|
524
|
+
|
|
525
|
+
if (isOnBlip || isOnDesc) {
|
|
526
|
+
const quadrant = parsed.quadrants.find((q) => q.blips.includes(blip))!;
|
|
527
|
+
const qColor = resolveQuadrantColor(
|
|
528
|
+
quadrant.position,
|
|
529
|
+
quadrant.color,
|
|
530
|
+
palette
|
|
531
|
+
);
|
|
532
|
+
// Show popover at the blip's position
|
|
533
|
+
const svgRect = (svg.node() as SVGSVGElement)?.getBoundingClientRect();
|
|
534
|
+
if (svgRect) {
|
|
535
|
+
const fakeEvent = {
|
|
536
|
+
clientX: svgRect.left + point.x,
|
|
537
|
+
clientY: svgRect.top + offsetY + point.y,
|
|
538
|
+
} as MouseEvent;
|
|
539
|
+
showBlipPopover(popover, blip, qColor, palette, isDark, fakeEvent);
|
|
540
|
+
}
|
|
541
|
+
// Scale up and dim
|
|
542
|
+
const lineNum = String(blip.lineNumber);
|
|
543
|
+
const blipEl = svg.select(`[data-line-number="${lineNum}"]`);
|
|
544
|
+
if (!blipEl.empty()) {
|
|
545
|
+
blipEl.attr(
|
|
546
|
+
'transform',
|
|
547
|
+
`translate(${point.x},${point.y}) scale(1.5) translate(${-point.x},${-point.y})`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
svg
|
|
551
|
+
.selectAll<SVGElement, unknown>('[data-line-number]')
|
|
552
|
+
.style('opacity', function () {
|
|
553
|
+
return this.getAttribute('data-line-number') === lineNum
|
|
554
|
+
? '1'
|
|
555
|
+
: String(DIM_OPACITY);
|
|
556
|
+
});
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── Four-column blip listing below radar ──
|
|
563
|
+
if (showListing) {
|
|
564
|
+
renderBlipListing(
|
|
565
|
+
svg,
|
|
566
|
+
parsed,
|
|
567
|
+
palette,
|
|
568
|
+
isDark,
|
|
569
|
+
textColor,
|
|
570
|
+
radarHeight + LISTING_TOP_MARGIN,
|
|
571
|
+
width,
|
|
572
|
+
onClickItem
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ============================================================
|
|
578
|
+
// Four-Column Listing
|
|
579
|
+
// ============================================================
|
|
580
|
+
|
|
581
|
+
const LISTING_BLIP_R = 11;
|
|
582
|
+
|
|
583
|
+
function renderBlipListing(
|
|
584
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
585
|
+
parsed: ParsedTechRadar,
|
|
586
|
+
palette: PaletteColors,
|
|
587
|
+
isDark: boolean,
|
|
588
|
+
textColor: string,
|
|
589
|
+
startY: number,
|
|
590
|
+
totalWidth: number,
|
|
591
|
+
onClickItem?: (lineNumber: number) => void
|
|
592
|
+
): void {
|
|
593
|
+
const colCount = parsed.quadrants.length;
|
|
594
|
+
if (colCount === 0) return;
|
|
595
|
+
|
|
596
|
+
const colWidth = (totalWidth - LISTING_COL_GAP * (colCount + 1)) / colCount;
|
|
597
|
+
|
|
598
|
+
// Sort quadrants in POSITION_ORDER
|
|
599
|
+
const sortedQuadrants = [...parsed.quadrants].sort(
|
|
600
|
+
(a, b) =>
|
|
601
|
+
POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position)
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
for (let ci = 0; ci < sortedQuadrants.length; ci++) {
|
|
605
|
+
const quadrant = sortedQuadrants[ci];
|
|
606
|
+
const qColor = resolveQuadrantColor(
|
|
607
|
+
quadrant.position,
|
|
608
|
+
quadrant.color,
|
|
609
|
+
palette
|
|
610
|
+
);
|
|
611
|
+
const colX = LISTING_COL_GAP + ci * (colWidth + LISTING_COL_GAP);
|
|
612
|
+
let y = startY;
|
|
613
|
+
|
|
614
|
+
// Column header — hover highlights entire quadrant on radar
|
|
615
|
+
const headerText = svg
|
|
616
|
+
.append('text')
|
|
617
|
+
.attr('x', colX)
|
|
618
|
+
.attr('y', y)
|
|
619
|
+
.attr('fill', qColor)
|
|
620
|
+
.attr('font-family', FONT_FAMILY)
|
|
621
|
+
.attr('font-size', LISTING_HEADER_FONT_SIZE)
|
|
622
|
+
.attr('font-weight', 'bold')
|
|
623
|
+
.style('cursor', 'pointer')
|
|
624
|
+
.text(quadrant.name);
|
|
625
|
+
|
|
626
|
+
const qPos = quadrant.position;
|
|
627
|
+
headerText
|
|
628
|
+
.on('mouseenter', () => {
|
|
629
|
+
// Dim everything except this quadrant's blips + ring segments
|
|
630
|
+
svg
|
|
631
|
+
.selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
|
|
632
|
+
.style('opacity', function () {
|
|
633
|
+
return this.getAttribute('data-quadrant') === qPos
|
|
634
|
+
? '1'
|
|
635
|
+
: String(DIM_OPACITY);
|
|
636
|
+
});
|
|
637
|
+
})
|
|
638
|
+
.on('mouseleave', () => {
|
|
639
|
+
svg
|
|
640
|
+
.selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
|
|
641
|
+
.style('opacity', '1');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
y += LISTING_LINE_HEIGHT + 6;
|
|
645
|
+
|
|
646
|
+
// Sort blips by globalNumber
|
|
647
|
+
const sortedBlips = [...quadrant.blips].sort(
|
|
648
|
+
(a, b) => a.globalNumber - b.globalNumber
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
for (const blip of sortedBlips) {
|
|
652
|
+
const itemGroup = svg
|
|
653
|
+
.append('g')
|
|
654
|
+
.attr('data-line-number', blip.lineNumber)
|
|
655
|
+
.attr('data-quadrant', quadrant.position)
|
|
656
|
+
.attr('data-ring', blip.ring)
|
|
657
|
+
.attr('data-trend', blip.trend ?? 'stable')
|
|
658
|
+
.style('cursor', onClickItem ? 'pointer' : 'default');
|
|
659
|
+
|
|
660
|
+
const blipCx = colX + LISTING_BLIP_R;
|
|
661
|
+
const blipCy = y - LISTING_BLIP_R + 2;
|
|
662
|
+
|
|
663
|
+
// Mini blip circle with trend indicator
|
|
664
|
+
// angleToCenter convention: "up" means center is above (angle = -π/2 in SVG)
|
|
665
|
+
// "down" means center is below (angle = π/2 in SVG)
|
|
666
|
+
// The shared renderer flips for "down", so we pass the "toward center" angle
|
|
667
|
+
const trendAngle = -Math.PI / 2; // "center" is always up for listing context
|
|
668
|
+
renderTrendIndicator(
|
|
669
|
+
itemGroup,
|
|
670
|
+
blip.trend,
|
|
671
|
+
qColor,
|
|
672
|
+
blipCx,
|
|
673
|
+
blipCy,
|
|
674
|
+
LISTING_BLIP_R,
|
|
675
|
+
trendAngle
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
// Number inside the circle
|
|
679
|
+
itemGroup
|
|
680
|
+
.append('text')
|
|
681
|
+
.attr('x', blipCx)
|
|
682
|
+
.attr('y', blipCy + 3)
|
|
683
|
+
.attr('text-anchor', 'middle')
|
|
684
|
+
.attr('fill', isDark ? '#000' : '#fff')
|
|
685
|
+
.attr('font-family', FONT_FAMILY)
|
|
686
|
+
.attr('font-size', 9)
|
|
687
|
+
.attr('font-weight', 'bold')
|
|
688
|
+
.text(blip.globalNumber);
|
|
689
|
+
|
|
690
|
+
// Blip name + ring — truncated to fit column width
|
|
691
|
+
const textX = colX + LISTING_BLIP_R * 2 + 6;
|
|
692
|
+
const availableWidth = colWidth - LISTING_BLIP_R * 2 - 8;
|
|
693
|
+
const fullLabel = `${blip.name} (${blip.ring})`;
|
|
694
|
+
const label = truncateLabel(fullLabel, availableWidth, LISTING_FONT_SIZE);
|
|
695
|
+
|
|
696
|
+
itemGroup
|
|
697
|
+
.append('text')
|
|
698
|
+
.attr('x', textX)
|
|
699
|
+
.attr('y', y)
|
|
700
|
+
.attr('fill', textColor)
|
|
701
|
+
.attr('font-family', FONT_FAMILY)
|
|
702
|
+
.attr('font-size', LISTING_FONT_SIZE)
|
|
703
|
+
.text(label);
|
|
704
|
+
|
|
705
|
+
// Cross-highlight: hover listing blip → highlight + scale up radar blip
|
|
706
|
+
const ln = String(blip.lineNumber);
|
|
707
|
+
itemGroup
|
|
708
|
+
.on('mouseenter', () => {
|
|
709
|
+
svg
|
|
710
|
+
.selectAll<SVGElement, unknown>('[data-line-number]')
|
|
711
|
+
.style('opacity', function () {
|
|
712
|
+
const isMatch = this.getAttribute('data-line-number') === ln;
|
|
713
|
+
// Scale up the matching radar blip (not listing items)
|
|
714
|
+
if (
|
|
715
|
+
isMatch &&
|
|
716
|
+
this.getAttribute('data-quadrant') &&
|
|
717
|
+
this.closest('g[transform]')
|
|
718
|
+
) {
|
|
719
|
+
const bbox = (this as SVGGraphicsElement).getBBox?.();
|
|
720
|
+
if (bbox) {
|
|
721
|
+
const bx = bbox.x + bbox.width / 2;
|
|
722
|
+
const by = bbox.y + bbox.height / 2;
|
|
723
|
+
this.setAttribute(
|
|
724
|
+
'transform',
|
|
725
|
+
`translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return isMatch ? '1' : String(DIM_OPACITY);
|
|
730
|
+
});
|
|
731
|
+
})
|
|
732
|
+
.on('mouseleave', () => {
|
|
733
|
+
svg
|
|
734
|
+
.selectAll<SVGElement, unknown>('[data-line-number]')
|
|
735
|
+
.style('opacity', '1')
|
|
736
|
+
.attr('transform', null);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
if (onClickItem) {
|
|
740
|
+
itemGroup.on('click', () => onClickItem(blip.lineNumber));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
y += LISTING_LINE_HEIGHT;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Estimate max characters that fit in `availablePx` at the given font size. */
|
|
749
|
+
function truncateLabel(
|
|
750
|
+
text: string,
|
|
751
|
+
availablePx: number,
|
|
752
|
+
fontSize: number
|
|
753
|
+
): string {
|
|
754
|
+
// Average character width ≈ 0.58 × fontSize for Helvetica/Inter
|
|
755
|
+
const avgCharWidth = fontSize * 0.58;
|
|
756
|
+
const maxChars = Math.floor(availablePx / avgCharWidth);
|
|
757
|
+
if (maxChars <= 0) return '';
|
|
758
|
+
if (text.length <= maxChars) return text;
|
|
759
|
+
return text.substring(0, maxChars - 1) + '\u2026';
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ============================================================
|
|
763
|
+
// Ring×Quadrant Hover Interactivity
|
|
764
|
+
// ============================================================
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Render transparent arc hit areas for each ring×quadrant slice.
|
|
768
|
+
* On hover, dims all blips (radar + listing) except those in the hovered slice.
|
|
769
|
+
*/
|
|
770
|
+
function renderRingHoverAreas(
|
|
771
|
+
radarGroup: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
772
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
773
|
+
parsed: ParsedTechRadar,
|
|
774
|
+
palette: PaletteColors,
|
|
775
|
+
cx: number,
|
|
776
|
+
cy: number,
|
|
777
|
+
ringBandWidth: number,
|
|
778
|
+
_maxRadius: number
|
|
779
|
+
): void {
|
|
780
|
+
for (const quadrant of parsed.quadrants) {
|
|
781
|
+
const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
|
|
782
|
+
const qColor = resolveQuadrantColor(
|
|
783
|
+
quadrant.position,
|
|
784
|
+
quadrant.color,
|
|
785
|
+
palette
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
for (let ri = 0; ri < parsed.rings.length; ri++) {
|
|
789
|
+
const innerR = ri * ringBandWidth;
|
|
790
|
+
const outerR = (ri + 1) * ringBandWidth;
|
|
791
|
+
const ringName = parsed.rings[ri].name;
|
|
792
|
+
|
|
793
|
+
const path = buildArcSlicePath(
|
|
794
|
+
cx,
|
|
795
|
+
cy,
|
|
796
|
+
innerR,
|
|
797
|
+
outerR,
|
|
798
|
+
startAngle,
|
|
799
|
+
endAngle
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
const hitArea = radarGroup
|
|
803
|
+
.append('path')
|
|
804
|
+
.attr('d', path)
|
|
805
|
+
.attr('fill', 'transparent')
|
|
806
|
+
.style('cursor', 'pointer');
|
|
807
|
+
|
|
808
|
+
hitArea
|
|
809
|
+
.on('mouseenter', () => {
|
|
810
|
+
// Tint the hovered slice via the overlay
|
|
811
|
+
hitArea.attr('fill', qColor).attr('opacity', 0.15);
|
|
812
|
+
// Dim all blips/listing except matching ring+quadrant
|
|
813
|
+
svg
|
|
814
|
+
.selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
|
|
815
|
+
.style('opacity', function () {
|
|
816
|
+
const q = this.getAttribute('data-quadrant');
|
|
817
|
+
const r = this.getAttribute('data-ring');
|
|
818
|
+
return q === quadrant.position && r === ringName
|
|
819
|
+
? '1'
|
|
820
|
+
: String(DIM_OPACITY);
|
|
821
|
+
});
|
|
822
|
+
})
|
|
823
|
+
.on('mouseleave', () => {
|
|
824
|
+
// Remove overlay tint
|
|
825
|
+
hitArea.attr('fill', 'transparent').attr('opacity', 1);
|
|
826
|
+
// Restore all opacities
|
|
827
|
+
svg
|
|
828
|
+
.selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
|
|
829
|
+
.style('opacity', '1');
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Build an SVG arc-slice path between inner and outer radius for a quadrant arc. */
|
|
836
|
+
function buildArcSlicePath(
|
|
837
|
+
cx: number,
|
|
838
|
+
cy: number,
|
|
839
|
+
innerR: number,
|
|
840
|
+
outerR: number,
|
|
841
|
+
startAngle: number,
|
|
842
|
+
endAngle: number
|
|
843
|
+
): string {
|
|
844
|
+
// Convert math angles to SVG coordinates (negate sin for Y-down)
|
|
845
|
+
const ox1 = cx + outerR * Math.cos(startAngle);
|
|
846
|
+
const oy1 = cy - outerR * Math.sin(startAngle);
|
|
847
|
+
const ox2 = cx + outerR * Math.cos(endAngle);
|
|
848
|
+
const oy2 = cy - outerR * Math.sin(endAngle);
|
|
849
|
+
const ix1 = cx + innerR * Math.cos(endAngle);
|
|
850
|
+
const iy1 = cy - innerR * Math.sin(endAngle);
|
|
851
|
+
const ix2 = cx + innerR * Math.cos(startAngle);
|
|
852
|
+
const iy2 = cy - innerR * Math.sin(startAngle);
|
|
853
|
+
|
|
854
|
+
if (innerR === 0) {
|
|
855
|
+
// Pie wedge from center
|
|
856
|
+
return `M${cx},${cy} L${ox1},${oy1} A${outerR},${outerR} 0 0,0 ${ox2},${oy2} Z`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return `M${ox1},${oy1} A${outerR},${outerR} 0 0,0 ${ox2},${oy2} L${ix1},${iy1} A${innerR},${innerR} 0 0,1 ${ix2},${iy2} Z`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ============================================================
|
|
863
|
+
// Trend Items (used by legend group entries)
|
|
864
|
+
// ============================================================
|
|
865
|
+
|
|
866
|
+
function estimateListingHeight(parsed: ParsedTechRadar): number {
|
|
867
|
+
const maxBlipsInQuadrant = Math.max(
|
|
868
|
+
0,
|
|
869
|
+
...parsed.quadrants.map((q) => q.blips.length)
|
|
870
|
+
);
|
|
871
|
+
return (
|
|
872
|
+
LISTING_LINE_HEIGHT * (maxBlipsInQuadrant + 1) +
|
|
873
|
+
LISTING_LINE_HEIGHT +
|
|
874
|
+
LISTING_TOP_MARGIN
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ============================================================
|
|
879
|
+
// Rich Blip Popover (B&L-style node card)
|
|
880
|
+
// ============================================================
|
|
881
|
+
|
|
882
|
+
import type { TechRadarBlip } from './types';
|
|
883
|
+
|
|
884
|
+
function createBlipPopover(
|
|
885
|
+
container: HTMLElement,
|
|
886
|
+
palette: PaletteColors,
|
|
887
|
+
isDark: boolean
|
|
888
|
+
): HTMLDivElement {
|
|
889
|
+
container.style.position = 'relative';
|
|
890
|
+
const existing = container.querySelector<HTMLDivElement>(
|
|
891
|
+
'[data-blip-popover]'
|
|
892
|
+
);
|
|
893
|
+
if (existing) {
|
|
894
|
+
existing.style.display = 'none';
|
|
895
|
+
return existing;
|
|
896
|
+
}
|
|
897
|
+
const el = document.createElement('div');
|
|
898
|
+
el.setAttribute('data-blip-popover', '');
|
|
899
|
+
el.style.position = 'absolute';
|
|
900
|
+
el.style.display = 'none';
|
|
901
|
+
el.style.pointerEvents = 'none';
|
|
902
|
+
el.style.zIndex = '20';
|
|
903
|
+
el.style.maxWidth = '280px';
|
|
904
|
+
el.style.fontFamily = FONT_FAMILY;
|
|
905
|
+
el.style.fontSize = '12px';
|
|
906
|
+
el.style.lineHeight = '1.5';
|
|
907
|
+
el.style.borderRadius = '6px';
|
|
908
|
+
el.style.overflow = 'hidden';
|
|
909
|
+
el.style.boxShadow = isDark
|
|
910
|
+
? '0 4px 12px rgba(0,0,0,0.4)'
|
|
911
|
+
: '0 4px 12px rgba(0,0,0,0.12)';
|
|
912
|
+
container.appendChild(el);
|
|
913
|
+
return el;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function showBlipPopover(
|
|
917
|
+
popover: HTMLDivElement,
|
|
918
|
+
blip: TechRadarBlip,
|
|
919
|
+
qColor: string,
|
|
920
|
+
palette: PaletteColors,
|
|
921
|
+
isDark: boolean,
|
|
922
|
+
event: MouseEvent
|
|
923
|
+
): void {
|
|
924
|
+
const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
|
|
925
|
+
const hasDesc = blip.description.length > 0;
|
|
926
|
+
|
|
927
|
+
let html = `<div style="background:${fillColor}; border: 1.5px solid ${qColor}; border-radius: 6px; overflow: hidden;">`;
|
|
928
|
+
html += `<div style="padding: 8px 12px; font-weight: 600; color: ${palette.text};">${escapeHtml(blip.name)}</div>`;
|
|
929
|
+
|
|
930
|
+
if (hasDesc) {
|
|
931
|
+
html += `<div style="border-top: 1px solid ${qColor}; opacity: 0.3;"></div>`;
|
|
932
|
+
html += `<div style="padding: 6px 12px 8px; color: ${palette.textMuted}; font-size: 11px; line-height: 1.6;">`;
|
|
933
|
+
// Join consecutive prose lines into paragraphs; bullets stay separate
|
|
934
|
+
const paragraphs = joinDescriptionParagraphs(blip.description);
|
|
935
|
+
for (const para of paragraphs) {
|
|
936
|
+
html += renderDescriptionLine(para, palette);
|
|
937
|
+
}
|
|
938
|
+
html += `</div>`;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
html += `</div>`;
|
|
942
|
+
|
|
943
|
+
popover.innerHTML = html;
|
|
944
|
+
popover.style.display = 'block';
|
|
945
|
+
positionPopover(popover, event);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function positionPopover(popover: HTMLDivElement, event: MouseEvent): void {
|
|
949
|
+
const container = popover.parentElement!;
|
|
950
|
+
const rect = container.getBoundingClientRect();
|
|
951
|
+
const tipW = popover.offsetWidth;
|
|
952
|
+
const tipH = popover.offsetHeight;
|
|
953
|
+
const cursorX = event.clientX - rect.left;
|
|
954
|
+
const cursorY = event.clientY - rect.top;
|
|
955
|
+
const centerX = rect.width / 2;
|
|
956
|
+
const centerY = rect.height / 2;
|
|
957
|
+
|
|
958
|
+
// Position toward the center of the diagram relative to the blip
|
|
959
|
+
let left = cursorX < centerX ? cursorX + 16 : cursorX - tipW - 16;
|
|
960
|
+
let top = cursorY < centerY ? cursorY + 16 : cursorY - tipH - 16;
|
|
961
|
+
|
|
962
|
+
// Clamp to container bounds
|
|
963
|
+
if (left + tipW > rect.width - 4) left = rect.width - tipW - 4;
|
|
964
|
+
if (left < 4) left = 4;
|
|
965
|
+
if (top + tipH > rect.height - 4) top = rect.height - tipH - 4;
|
|
966
|
+
if (top < 4) top = 4;
|
|
967
|
+
|
|
968
|
+
popover.style.left = `${left}px`;
|
|
969
|
+
popover.style.top = `${top}px`;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function hideBlipPopover(popover: HTMLDivElement): void {
|
|
973
|
+
popover.style.display = 'none';
|
|
974
|
+
popover.style.pointerEvents = 'none';
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Join consecutive prose lines into single paragraphs.
|
|
979
|
+
* Bullets (lines starting with -, *, •) stay as separate entries.
|
|
980
|
+
* Blank lines create paragraph breaks.
|
|
981
|
+
*/
|
|
982
|
+
function joinDescriptionParagraphs(lines: string[]): string[] {
|
|
983
|
+
const result: string[] = [];
|
|
984
|
+
let currentPara = '';
|
|
985
|
+
|
|
986
|
+
for (const line of lines) {
|
|
987
|
+
const trimmed = line.trim();
|
|
988
|
+
const isBullet = /^[-*•]\s+/.test(trimmed);
|
|
989
|
+
|
|
990
|
+
if (isBullet) {
|
|
991
|
+
// Flush any accumulated paragraph
|
|
992
|
+
if (currentPara) {
|
|
993
|
+
result.push(currentPara);
|
|
994
|
+
currentPara = '';
|
|
995
|
+
}
|
|
996
|
+
result.push(trimmed);
|
|
997
|
+
} else if (!trimmed) {
|
|
998
|
+
// Blank line — paragraph break
|
|
999
|
+
if (currentPara) {
|
|
1000
|
+
result.push(currentPara);
|
|
1001
|
+
currentPara = '';
|
|
1002
|
+
}
|
|
1003
|
+
} else {
|
|
1004
|
+
// Prose line — join with previous
|
|
1005
|
+
currentPara = currentPara ? `${currentPara} ${trimmed}` : trimmed;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (currentPara) result.push(currentPara);
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function renderDescriptionLine(line: string, palette: PaletteColors): string {
|
|
1014
|
+
const trimmed = line.trim();
|
|
1015
|
+
const isBullet = /^[-*•]\s+/.test(trimmed);
|
|
1016
|
+
const content = isBullet ? trimmed.replace(/^[-*•]\s+/, '') : trimmed;
|
|
1017
|
+
|
|
1018
|
+
const spans = parseInlineMarkdown(content);
|
|
1019
|
+
let spanHtml = '';
|
|
1020
|
+
for (const span of spans) {
|
|
1021
|
+
let text = escapeHtml(span.text);
|
|
1022
|
+
if (span.bold) text = `<strong>${text}</strong>`;
|
|
1023
|
+
if (span.italic) text = `<em>${text}</em>`;
|
|
1024
|
+
if (span.code)
|
|
1025
|
+
text = `<code style="background:${palette.surface}; padding: 1px 4px; border-radius: 3px; font-size: 10px;">${text}</code>`;
|
|
1026
|
+
if (span.href)
|
|
1027
|
+
text = `<a href="${escapeHtml(span.href)}" target="_blank" rel="noopener" style="color: ${palette.primary ?? palette.text}; text-decoration: underline;">${text}</a>`;
|
|
1028
|
+
spanHtml += text;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (isBullet) {
|
|
1032
|
+
return `<div style="padding-left: 12px; text-indent: -10px; margin: 1px 0;">• ${spanHtml}</div>`;
|
|
1033
|
+
}
|
|
1034
|
+
return `<div style="margin: 2px 0;">${spanHtml}</div>`;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function escapeHtml(text: string): string {
|
|
1038
|
+
return text
|
|
1039
|
+
.replace(/&/g, '&')
|
|
1040
|
+
.replace(/</g, '<')
|
|
1041
|
+
.replace(/>/g, '>');
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ============================================================
|
|
1045
|
+
// Quadrant Label Positioning
|
|
1046
|
+
// ============================================================
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Render a quadrant label, wrapping to multiple lines if needed and
|
|
1050
|
+
* scaling font down if the text is too wide for the available space.
|
|
1051
|
+
*/
|
|
1052
|
+
function renderQuadrantLabel(
|
|
1053
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1054
|
+
name: string,
|
|
1055
|
+
x: number,
|
|
1056
|
+
y: number,
|
|
1057
|
+
anchor: string,
|
|
1058
|
+
color: string,
|
|
1059
|
+
maxWidth: number
|
|
1060
|
+
): void {
|
|
1061
|
+
const avgCharWidth = QUADRANT_LABEL_FONT_SIZE * 0.58;
|
|
1062
|
+
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
|
|
1063
|
+
|
|
1064
|
+
// Split into words and wrap
|
|
1065
|
+
const words = name.split(/\s+/);
|
|
1066
|
+
const lines: string[] = [];
|
|
1067
|
+
let currentLine = '';
|
|
1068
|
+
|
|
1069
|
+
for (const word of words) {
|
|
1070
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
1071
|
+
if (testLine.length > maxCharsPerLine && currentLine) {
|
|
1072
|
+
lines.push(currentLine);
|
|
1073
|
+
currentLine = word;
|
|
1074
|
+
} else {
|
|
1075
|
+
currentLine = testLine;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (currentLine) lines.push(currentLine);
|
|
1079
|
+
|
|
1080
|
+
// Scale font down if any line is still too wide
|
|
1081
|
+
const longestLine = Math.max(...lines.map((l) => l.length));
|
|
1082
|
+
const estimatedWidth = longestLine * avgCharWidth;
|
|
1083
|
+
const fontSize =
|
|
1084
|
+
estimatedWidth > maxWidth
|
|
1085
|
+
? Math.max(12, QUADRANT_LABEL_FONT_SIZE * (maxWidth / estimatedWidth))
|
|
1086
|
+
: QUADRANT_LABEL_FONT_SIZE;
|
|
1087
|
+
|
|
1088
|
+
const lineHeight = fontSize * 1.2;
|
|
1089
|
+
|
|
1090
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1091
|
+
g.append('text')
|
|
1092
|
+
.attr('x', x)
|
|
1093
|
+
.attr('y', y + i * lineHeight)
|
|
1094
|
+
.attr('text-anchor', anchor)
|
|
1095
|
+
.attr('fill', color)
|
|
1096
|
+
.attr('font-family', FONT_FAMILY)
|
|
1097
|
+
.attr('font-size', fontSize)
|
|
1098
|
+
.attr('font-weight', 'bold')
|
|
1099
|
+
.text(lines[i]);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function getQuadrantLabelPosition(
|
|
1104
|
+
position: QuadrantPosition,
|
|
1105
|
+
cx: number,
|
|
1106
|
+
cy: number,
|
|
1107
|
+
maxRadius: number
|
|
1108
|
+
): { x: number; y: number; anchor: string } {
|
|
1109
|
+
const margin = 8;
|
|
1110
|
+
switch (position) {
|
|
1111
|
+
case 'top-left':
|
|
1112
|
+
return {
|
|
1113
|
+
x: cx - maxRadius + margin,
|
|
1114
|
+
y: cy - maxRadius + 16,
|
|
1115
|
+
anchor: 'start',
|
|
1116
|
+
};
|
|
1117
|
+
case 'top-right':
|
|
1118
|
+
return {
|
|
1119
|
+
x: cx + maxRadius - margin,
|
|
1120
|
+
y: cy - maxRadius + 16,
|
|
1121
|
+
anchor: 'end',
|
|
1122
|
+
};
|
|
1123
|
+
case 'bottom-left':
|
|
1124
|
+
return {
|
|
1125
|
+
x: cx - maxRadius + margin,
|
|
1126
|
+
y: cy + maxRadius - 8,
|
|
1127
|
+
anchor: 'start',
|
|
1128
|
+
};
|
|
1129
|
+
case 'bottom-right':
|
|
1130
|
+
return {
|
|
1131
|
+
x: cx + maxRadius - margin,
|
|
1132
|
+
y: cy + maxRadius - 8,
|
|
1133
|
+
anchor: 'end',
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// ============================================================
|
|
1139
|
+
// Export Renderer (for static SVG/PNG export)
|
|
1140
|
+
// ============================================================
|
|
1141
|
+
|
|
1142
|
+
export function renderTechRadarForExport(
|
|
1143
|
+
container: HTMLDivElement,
|
|
1144
|
+
parsed: ParsedTechRadar,
|
|
1145
|
+
palette: PaletteColors,
|
|
1146
|
+
isDark: boolean,
|
|
1147
|
+
exportDims?: D3ExportDimensions,
|
|
1148
|
+
viewState?: CompactViewState
|
|
1149
|
+
): void {
|
|
1150
|
+
renderTechRadar(
|
|
1151
|
+
container,
|
|
1152
|
+
parsed,
|
|
1153
|
+
palette,
|
|
1154
|
+
isDark,
|
|
1155
|
+
undefined,
|
|
1156
|
+
exportDims,
|
|
1157
|
+
viewState
|
|
1158
|
+
);
|
|
1159
|
+
}
|