@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,1112 @@
|
|
|
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 {
|
|
7
|
+
ParsedTechRadar,
|
|
8
|
+
QuadrantPosition,
|
|
9
|
+
TechRadarRenderOptions,
|
|
10
|
+
} from './types';
|
|
11
|
+
import { getQuadrantArc } from './layout';
|
|
12
|
+
import {
|
|
13
|
+
resolveQuadrantColor,
|
|
14
|
+
renderTrendIndicator,
|
|
15
|
+
createTooltip,
|
|
16
|
+
DIM_OPACITY,
|
|
17
|
+
} from './shared';
|
|
18
|
+
import { parseInlineMarkdown } from '../utils/inline-markdown';
|
|
19
|
+
|
|
20
|
+
// ============================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================
|
|
23
|
+
|
|
24
|
+
const BLIP_RADIUS = 13;
|
|
25
|
+
const BLIP_FONT_SIZE = 10;
|
|
26
|
+
const TITLE_FONT_SIZE = 16;
|
|
27
|
+
const NARROW_BREAKPOINT = 600;
|
|
28
|
+
|
|
29
|
+
// ============================================================
|
|
30
|
+
// Quadrant Focus Renderer
|
|
31
|
+
// ============================================================
|
|
32
|
+
|
|
33
|
+
export function renderQuadrantFocus(
|
|
34
|
+
container: HTMLDivElement,
|
|
35
|
+
parsed: ParsedTechRadar,
|
|
36
|
+
quadrantPosition: QuadrantPosition,
|
|
37
|
+
palette: PaletteColors,
|
|
38
|
+
isDark: boolean,
|
|
39
|
+
onClickItem?: (lineNumber: number) => void,
|
|
40
|
+
exportDims?: D3ExportDimensions,
|
|
41
|
+
_options?: TechRadarRenderOptions
|
|
42
|
+
): void {
|
|
43
|
+
const quadrant = parsed.quadrants.find(
|
|
44
|
+
(q) => q.position === quadrantPosition
|
|
45
|
+
);
|
|
46
|
+
if (!quadrant) return;
|
|
47
|
+
|
|
48
|
+
// Clear container
|
|
49
|
+
container.innerHTML = '';
|
|
50
|
+
|
|
51
|
+
const width = exportDims?.width ?? container.clientWidth;
|
|
52
|
+
const height = exportDims?.height ?? container.clientHeight;
|
|
53
|
+
if (width <= 0 || height <= 0) return;
|
|
54
|
+
|
|
55
|
+
const isNarrow = width < NARROW_BREAKPOINT;
|
|
56
|
+
const qColor = resolveQuadrantColor(
|
|
57
|
+
quadrant.position,
|
|
58
|
+
quadrant.color,
|
|
59
|
+
palette
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// ── Breadcrumb title ──
|
|
63
|
+
const titleBar = document.createElement('div');
|
|
64
|
+
titleBar.style.cssText = `
|
|
65
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
66
|
+
padding: 8px 12px; font-family: ${FONT_FAMILY};
|
|
67
|
+
font-size: ${TITLE_FONT_SIZE}px; background: ${palette.bg};
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const titleLink = document.createElement('span');
|
|
71
|
+
titleLink.textContent = parsed.title || 'Tech Radar';
|
|
72
|
+
titleLink.style.cssText = `font-weight: bold; color: ${palette.text}; cursor: pointer;`;
|
|
73
|
+
titleLink.setAttribute('data-line-number', String(parsed.titleLineNumber));
|
|
74
|
+
|
|
75
|
+
const sep = document.createElement('span');
|
|
76
|
+
sep.textContent = '›';
|
|
77
|
+
sep.style.color = palette.border;
|
|
78
|
+
|
|
79
|
+
const quadrantLabel = document.createElement('span');
|
|
80
|
+
quadrantLabel.textContent = quadrant.name;
|
|
81
|
+
quadrantLabel.style.cssText = `font-weight: bold; color: ${qColor};`;
|
|
82
|
+
|
|
83
|
+
titleBar.appendChild(titleLink);
|
|
84
|
+
titleBar.appendChild(sep);
|
|
85
|
+
titleBar.appendChild(quadrantLabel);
|
|
86
|
+
container.appendChild(titleBar);
|
|
87
|
+
|
|
88
|
+
// ── Main layout: SVG radar (left) + HTML panel (right) ──
|
|
89
|
+
const mainLayout = document.createElement('div');
|
|
90
|
+
mainLayout.style.cssText = `
|
|
91
|
+
display: flex; flex-direction: ${isNarrow ? 'column' : 'row'};
|
|
92
|
+
flex: 1; min-height: 0; height: calc(100% - 40px); background: ${palette.bg};
|
|
93
|
+
`;
|
|
94
|
+
container.appendChild(mainLayout);
|
|
95
|
+
|
|
96
|
+
// SVG container for quarter-circle
|
|
97
|
+
const svgContainer = document.createElement('div');
|
|
98
|
+
svgContainer.style.cssText = `
|
|
99
|
+
${isNarrow ? 'height: 40%;' : 'width: 50%; min-width: 200px;'}
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
`;
|
|
102
|
+
mainLayout.appendChild(svgContainer);
|
|
103
|
+
|
|
104
|
+
// HTML panel for blip listing
|
|
105
|
+
const panel = document.createElement('div');
|
|
106
|
+
panel.style.cssText = `
|
|
107
|
+
flex: 1; overflow-y: auto; padding: 8px 12px;
|
|
108
|
+
font-family: ${FONT_FAMILY}; background: ${palette.bg};
|
|
109
|
+
`;
|
|
110
|
+
mainLayout.appendChild(panel);
|
|
111
|
+
|
|
112
|
+
// ── Render HTML side panel first (returns toggle callback for radar clicks) ──
|
|
113
|
+
const toggleBlip = renderHtmlPanel(
|
|
114
|
+
panel,
|
|
115
|
+
parsed,
|
|
116
|
+
quadrant,
|
|
117
|
+
qColor,
|
|
118
|
+
palette,
|
|
119
|
+
isDark,
|
|
120
|
+
container,
|
|
121
|
+
onClickItem
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// ── Render quarter-circle SVG ──
|
|
125
|
+
const svgWidth = svgContainer.clientWidth || (isNarrow ? width : width * 0.5);
|
|
126
|
+
const svgHeight =
|
|
127
|
+
svgContainer.clientHeight || (isNarrow ? height * 0.4 : height - 40);
|
|
128
|
+
|
|
129
|
+
const svg = d3Selection
|
|
130
|
+
.select(svgContainer)
|
|
131
|
+
.append('svg')
|
|
132
|
+
.attr('width', '100%')
|
|
133
|
+
.attr('height', '100%')
|
|
134
|
+
.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`)
|
|
135
|
+
.style('background', palette.bg);
|
|
136
|
+
|
|
137
|
+
const tooltip = createTooltip(container, palette, isDark);
|
|
138
|
+
|
|
139
|
+
renderQuarterCircle(
|
|
140
|
+
svg,
|
|
141
|
+
parsed,
|
|
142
|
+
quadrant,
|
|
143
|
+
qColor,
|
|
144
|
+
palette,
|
|
145
|
+
isDark,
|
|
146
|
+
svgWidth,
|
|
147
|
+
svgHeight,
|
|
148
|
+
palette.border,
|
|
149
|
+
tooltip,
|
|
150
|
+
container,
|
|
151
|
+
toggleBlip
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// ── Click handlers for title (back navigation) ──
|
|
155
|
+
titleLink.addEventListener('click', () => {
|
|
156
|
+
if (onClickItem) onClickItem(parsed.titleLineNumber);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── Active line from editor cursor → expand that blip in the panel ──
|
|
160
|
+
if (_options?.activeLine) {
|
|
161
|
+
const activeLn = _options.activeLine;
|
|
162
|
+
for (const blip of quadrant.blips) {
|
|
163
|
+
const isOnBlip = blip.lineNumber === activeLn;
|
|
164
|
+
const isOnDesc =
|
|
165
|
+
blip.description.length > 0 &&
|
|
166
|
+
activeLn > blip.lineNumber &&
|
|
167
|
+
activeLn <= blip.lineNumber + blip.description.length;
|
|
168
|
+
if (isOnBlip || isOnDesc) {
|
|
169
|
+
toggleBlip(blip.lineNumber);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================
|
|
177
|
+
// Quarter-Circle SVG Rendering
|
|
178
|
+
// ============================================================
|
|
179
|
+
|
|
180
|
+
function renderQuarterCircle(
|
|
181
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
182
|
+
parsed: ParsedTechRadar,
|
|
183
|
+
quadrant: ParsedTechRadar['quadrants'][number],
|
|
184
|
+
qColor: string,
|
|
185
|
+
palette: PaletteColors,
|
|
186
|
+
isDark: boolean,
|
|
187
|
+
width: number,
|
|
188
|
+
height: number,
|
|
189
|
+
mutedColor: string,
|
|
190
|
+
tooltip: HTMLDivElement,
|
|
191
|
+
rootContainer: HTMLElement,
|
|
192
|
+
onClickItem?: (lineNumber: number) => void
|
|
193
|
+
): void {
|
|
194
|
+
const padding = 8;
|
|
195
|
+
const size = Math.min(width - padding, height - padding);
|
|
196
|
+
const maxRadius = size * 0.95;
|
|
197
|
+
const ringCount = parsed.rings.length;
|
|
198
|
+
const ringBandWidth = maxRadius / ringCount;
|
|
199
|
+
|
|
200
|
+
const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
|
|
201
|
+
let cx: number, cy: number;
|
|
202
|
+
|
|
203
|
+
switch (quadrant.position) {
|
|
204
|
+
case 'top-right':
|
|
205
|
+
cx = padding;
|
|
206
|
+
cy = size + padding;
|
|
207
|
+
break;
|
|
208
|
+
case 'top-left':
|
|
209
|
+
cx = width - padding;
|
|
210
|
+
cy = size + padding;
|
|
211
|
+
break;
|
|
212
|
+
case 'bottom-left':
|
|
213
|
+
cx = width - padding;
|
|
214
|
+
cy = padding;
|
|
215
|
+
break;
|
|
216
|
+
case 'bottom-right':
|
|
217
|
+
cx = padding;
|
|
218
|
+
cy = padding;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Ring arcs with zebra shading
|
|
223
|
+
const arcGen = (innerR: number, outerR: number) =>
|
|
224
|
+
`M${cx + outerR * Math.cos(startAngle)},${cy - outerR * Math.sin(startAngle)} A${outerR},${outerR} 0 0,0 ${cx + outerR * Math.cos(endAngle)},${cy - outerR * Math.sin(endAngle)} L${cx + innerR * Math.cos(endAngle)},${cy - innerR * Math.sin(endAngle)} A${innerR},${innerR} 0 0,1 ${cx + innerR * Math.cos(startAngle)},${cy - innerR * Math.sin(startAngle)} Z`;
|
|
225
|
+
|
|
226
|
+
for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
|
|
227
|
+
const innerR = ri * ringBandWidth;
|
|
228
|
+
const outerR = (ri + 1) * ringBandWidth;
|
|
229
|
+
const fillColor =
|
|
230
|
+
ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
|
|
231
|
+
|
|
232
|
+
const ringName = parsed.rings[ri].name;
|
|
233
|
+
|
|
234
|
+
// Background ring arc
|
|
235
|
+
svg
|
|
236
|
+
.append('path')
|
|
237
|
+
.attr('d', arcGen(innerR, outerR))
|
|
238
|
+
.attr('fill', fillColor)
|
|
239
|
+
.attr('stroke', mutedColor)
|
|
240
|
+
.attr('stroke-width', 0.5);
|
|
241
|
+
|
|
242
|
+
// Transparent hover overlay for ring interaction
|
|
243
|
+
svg
|
|
244
|
+
.append('path')
|
|
245
|
+
.attr('d', arcGen(innerR, outerR))
|
|
246
|
+
.attr('fill', 'transparent')
|
|
247
|
+
.attr('data-ring-arc', ringName)
|
|
248
|
+
.style('cursor', 'pointer')
|
|
249
|
+
.on('mouseenter', () => {
|
|
250
|
+
// Tint the hovered ring arc
|
|
251
|
+
d3Selection
|
|
252
|
+
.select(rootContainer)
|
|
253
|
+
.selectAll<SVGPathElement, unknown>('[data-ring-arc]')
|
|
254
|
+
.each(function () {
|
|
255
|
+
const el = d3Selection.select(this);
|
|
256
|
+
const isMatch = this.getAttribute('data-ring-arc') === ringName;
|
|
257
|
+
el.attr('fill', isMatch ? qColor : 'transparent').attr(
|
|
258
|
+
'opacity',
|
|
259
|
+
isMatch ? 0.15 : 1
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
dimExceptRing(rootContainer, ringName);
|
|
263
|
+
})
|
|
264
|
+
.on('mouseleave', () => {
|
|
265
|
+
d3Selection
|
|
266
|
+
.select(rootContainer)
|
|
267
|
+
.selectAll<SVGPathElement, unknown>('[data-ring-arc]')
|
|
268
|
+
.attr('fill', 'transparent')
|
|
269
|
+
.attr('opacity', 1);
|
|
270
|
+
clearDim(rootContainer);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Ring labels removed — the side panel ring headers serve this purpose
|
|
275
|
+
|
|
276
|
+
// Blip dots
|
|
277
|
+
const ringOrder = parsed.rings.map((r) => r.name);
|
|
278
|
+
const angularPadding = 0.08;
|
|
279
|
+
const radialPadding = ringBandWidth * 0.12;
|
|
280
|
+
const usableArcStart = startAngle + angularPadding;
|
|
281
|
+
const usableArcEnd = endAngle - angularPadding;
|
|
282
|
+
const arcSpan = usableArcEnd - usableArcStart;
|
|
283
|
+
|
|
284
|
+
const blipsByRing = new Map<string, typeof quadrant.blips>();
|
|
285
|
+
for (const blip of quadrant.blips) {
|
|
286
|
+
const list = blipsByRing.get(blip.ring) ?? [];
|
|
287
|
+
list.push(blip);
|
|
288
|
+
blipsByRing.set(blip.ring, list);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const [ringName, blips] of blipsByRing) {
|
|
292
|
+
const ringIndex = ringOrder.indexOf(ringName);
|
|
293
|
+
if (ringIndex < 0) continue;
|
|
294
|
+
const rInner = ringIndex * ringBandWidth + radialPadding;
|
|
295
|
+
const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
|
|
296
|
+
const rMid = (rInner + rOuter) / 2;
|
|
297
|
+
|
|
298
|
+
for (let bi = 0; bi < blips.length; bi++) {
|
|
299
|
+
const blip = blips[bi];
|
|
300
|
+
const angle =
|
|
301
|
+
blips.length === 1
|
|
302
|
+
? (usableArcStart + usableArcEnd) / 2
|
|
303
|
+
: usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
|
|
304
|
+
|
|
305
|
+
const radius =
|
|
306
|
+
blips.length <= 3
|
|
307
|
+
? rMid
|
|
308
|
+
: rInner +
|
|
309
|
+
BLIP_RADIUS +
|
|
310
|
+
((bi % 3) / 2) * (rOuter - rInner - BLIP_RADIUS * 2);
|
|
311
|
+
|
|
312
|
+
const bx = cx + radius * Math.cos(angle);
|
|
313
|
+
const by = cy - radius * Math.sin(angle);
|
|
314
|
+
|
|
315
|
+
const blipGroup = svg
|
|
316
|
+
.append('g')
|
|
317
|
+
.attr('data-line-number', blip.lineNumber)
|
|
318
|
+
.attr('data-ring', blip.ring)
|
|
319
|
+
.attr('data-trend', blip.trend ?? 'stable')
|
|
320
|
+
.style('cursor', 'pointer');
|
|
321
|
+
|
|
322
|
+
const angleToCenter = Math.atan2(cy - by, cx - bx);
|
|
323
|
+
renderTrendIndicator(
|
|
324
|
+
blipGroup,
|
|
325
|
+
blip.trend,
|
|
326
|
+
qColor,
|
|
327
|
+
bx,
|
|
328
|
+
by,
|
|
329
|
+
BLIP_RADIUS,
|
|
330
|
+
angleToCenter
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
blipGroup
|
|
334
|
+
.append('text')
|
|
335
|
+
.attr('x', bx)
|
|
336
|
+
.attr('y', by + 3)
|
|
337
|
+
.attr('text-anchor', 'middle')
|
|
338
|
+
.attr('fill', isDark ? '#000' : '#fff')
|
|
339
|
+
.attr('font-family', FONT_FAMILY)
|
|
340
|
+
.attr('font-size', BLIP_FONT_SIZE)
|
|
341
|
+
.attr('font-weight', 'bold')
|
|
342
|
+
.text(blip.globalNumber);
|
|
343
|
+
|
|
344
|
+
// Hover: scale up + dim others (preview only, no expansion)
|
|
345
|
+
const lineNum = String(blip.lineNumber);
|
|
346
|
+
blipGroup
|
|
347
|
+
.on('mouseenter', () => {
|
|
348
|
+
blipGroup.attr(
|
|
349
|
+
'transform',
|
|
350
|
+
`translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
|
|
351
|
+
);
|
|
352
|
+
dimExcept(rootContainer, lineNum);
|
|
353
|
+
})
|
|
354
|
+
.on('mouseleave', () => {
|
|
355
|
+
blipGroup.attr('transform', null);
|
|
356
|
+
clearDim(rootContainer);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Click: expand description in panel (persistent)
|
|
360
|
+
blipGroup.on('click', () => {
|
|
361
|
+
if (onClickItem) {
|
|
362
|
+
onClickItem(blip.lineNumber);
|
|
363
|
+
requestAnimationFrame(() => dimExcept(rootContainer, lineNum));
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ============================================================
|
|
371
|
+
// Cross-highlight helpers (work across SVG + HTML)
|
|
372
|
+
// ============================================================
|
|
373
|
+
|
|
374
|
+
function dimExcept(root: HTMLElement, lineNum: string): void {
|
|
375
|
+
root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
|
|
376
|
+
el.style.opacity =
|
|
377
|
+
el.getAttribute('data-line-number') === lineNum
|
|
378
|
+
? '1'
|
|
379
|
+
: String(DIM_OPACITY);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function dimExceptRing(root: HTMLElement, ringName: string): void {
|
|
384
|
+
// Dim blips not in the hovered ring (SVG + HTML)
|
|
385
|
+
root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
|
|
386
|
+
el.style.opacity =
|
|
387
|
+
el.getAttribute('data-ring') === ringName ? '1' : String(DIM_OPACITY);
|
|
388
|
+
});
|
|
389
|
+
// Dim ring groups not matching
|
|
390
|
+
root.querySelectorAll<HTMLElement>('[data-ring-group]').forEach((el) => {
|
|
391
|
+
el.style.opacity =
|
|
392
|
+
el.getAttribute('data-ring-group') === ringName
|
|
393
|
+
? '1'
|
|
394
|
+
: String(DIM_OPACITY);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function clearDim(root: HTMLElement): void {
|
|
399
|
+
root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
|
|
400
|
+
el.style.opacity = '1';
|
|
401
|
+
});
|
|
402
|
+
root.querySelectorAll<HTMLElement>('[data-ring-group]').forEach((el) => {
|
|
403
|
+
el.style.opacity = '1';
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ============================================================
|
|
408
|
+
// HTML Side Panel
|
|
409
|
+
// ============================================================
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Render the HTML side panel. Returns a toggle callback that the radar
|
|
413
|
+
* can call to expand/scroll a blip by line number.
|
|
414
|
+
*/
|
|
415
|
+
function renderHtmlPanel(
|
|
416
|
+
panel: HTMLElement,
|
|
417
|
+
parsed: ParsedTechRadar,
|
|
418
|
+
quadrant: ParsedTechRadar['quadrants'][number],
|
|
419
|
+
qColor: string,
|
|
420
|
+
palette: PaletteColors,
|
|
421
|
+
isDark: boolean,
|
|
422
|
+
rootContainer: HTMLElement,
|
|
423
|
+
onClickItem?: (lineNumber: number) => void
|
|
424
|
+
): (lineNumber: number) => void {
|
|
425
|
+
const ringOrder = parsed.rings.map((r) => r.name);
|
|
426
|
+
const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
|
|
427
|
+
let expandedLineNum: string | null = null;
|
|
428
|
+
|
|
429
|
+
function render() {
|
|
430
|
+
panel.innerHTML = '';
|
|
431
|
+
|
|
432
|
+
for (const ringName of ringOrder) {
|
|
433
|
+
const blips = quadrant.blips.filter((b) => b.ring === ringName);
|
|
434
|
+
if (blips.length === 0) continue;
|
|
435
|
+
|
|
436
|
+
// Ring group container
|
|
437
|
+
const ringGroup = document.createElement('div');
|
|
438
|
+
ringGroup.setAttribute('data-ring-group', ringName);
|
|
439
|
+
ringGroup.style.cssText = `
|
|
440
|
+
background: ${palette.surface};
|
|
441
|
+
border-radius: 8px;
|
|
442
|
+
padding: 10px;
|
|
443
|
+
margin-bottom: 12px;
|
|
444
|
+
transition: opacity 0.15s;
|
|
445
|
+
`;
|
|
446
|
+
|
|
447
|
+
// Ring header inside the group
|
|
448
|
+
const header = document.createElement('div');
|
|
449
|
+
header.style.cssText = `
|
|
450
|
+
font-size: 13px; font-weight: 700; color: ${palette.textMuted};
|
|
451
|
+
margin-bottom: 8px;
|
|
452
|
+
`;
|
|
453
|
+
header.textContent = ringName;
|
|
454
|
+
ringGroup.appendChild(header);
|
|
455
|
+
|
|
456
|
+
panel.appendChild(ringGroup);
|
|
457
|
+
|
|
458
|
+
// Blip nodes (appended to ringGroup, not panel)
|
|
459
|
+
for (const blip of blips) {
|
|
460
|
+
const ln = String(blip.lineNumber);
|
|
461
|
+
const isExpanded = expandedLineNum === ln;
|
|
462
|
+
const hasDesc = blip.description.length > 0;
|
|
463
|
+
|
|
464
|
+
const node = document.createElement('div');
|
|
465
|
+
node.setAttribute('data-line-number', ln);
|
|
466
|
+
node.setAttribute('data-ring', blip.ring);
|
|
467
|
+
node.setAttribute('data-trend', blip.trend ?? 'stable');
|
|
468
|
+
node.style.cssText = `
|
|
469
|
+
background: ${fillColor}; border: 1.5px solid ${qColor};
|
|
470
|
+
border-radius: 6px; margin-bottom: 6px; cursor: pointer;
|
|
471
|
+
transition: border-width 0.1s;
|
|
472
|
+
${isExpanded ? 'border-width: 2px;' : ''}
|
|
473
|
+
`;
|
|
474
|
+
|
|
475
|
+
// Title row
|
|
476
|
+
const titleRow = document.createElement('div');
|
|
477
|
+
titleRow.style.cssText = `
|
|
478
|
+
display: flex; align-items: center; gap: 8px;
|
|
479
|
+
padding: 6px 10px; min-height: 28px;
|
|
480
|
+
`;
|
|
481
|
+
|
|
482
|
+
// Mini SVG blip indicator
|
|
483
|
+
const indicatorSvg = document.createElementNS(
|
|
484
|
+
'http://www.w3.org/2000/svg',
|
|
485
|
+
'svg'
|
|
486
|
+
);
|
|
487
|
+
indicatorSvg.setAttribute('width', '26');
|
|
488
|
+
indicatorSvg.setAttribute('height', '26');
|
|
489
|
+
indicatorSvg.style.flexShrink = '0';
|
|
490
|
+
const indicatorG = d3Selection
|
|
491
|
+
.select(indicatorSvg)
|
|
492
|
+
.append('g') as d3Selection.Selection<
|
|
493
|
+
SVGGElement,
|
|
494
|
+
unknown,
|
|
495
|
+
null,
|
|
496
|
+
undefined
|
|
497
|
+
>;
|
|
498
|
+
renderTrendIndicator(
|
|
499
|
+
indicatorG,
|
|
500
|
+
blip.trend,
|
|
501
|
+
qColor,
|
|
502
|
+
13,
|
|
503
|
+
13,
|
|
504
|
+
10,
|
|
505
|
+
-Math.PI / 2
|
|
506
|
+
);
|
|
507
|
+
d3Selection
|
|
508
|
+
.select(indicatorSvg)
|
|
509
|
+
.append('text')
|
|
510
|
+
.attr('x', 13)
|
|
511
|
+
.attr('y', 16)
|
|
512
|
+
.attr('text-anchor', 'middle')
|
|
513
|
+
.attr('fill', isDark ? '#000' : '#fff')
|
|
514
|
+
.attr('font-family', FONT_FAMILY)
|
|
515
|
+
.attr('font-size', 9)
|
|
516
|
+
.attr('font-weight', 'bold')
|
|
517
|
+
.text(blip.globalNumber);
|
|
518
|
+
titleRow.appendChild(indicatorSvg);
|
|
519
|
+
|
|
520
|
+
// Name
|
|
521
|
+
const name = document.createElement('span');
|
|
522
|
+
name.textContent = blip.name;
|
|
523
|
+
name.style.cssText = `
|
|
524
|
+
flex: 1; font-size: 12px; font-weight: 600;
|
|
525
|
+
color: ${palette.text}; white-space: nowrap;
|
|
526
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
527
|
+
`;
|
|
528
|
+
titleRow.appendChild(name);
|
|
529
|
+
|
|
530
|
+
node.appendChild(titleRow);
|
|
531
|
+
|
|
532
|
+
// Description (expanded only)
|
|
533
|
+
if (isExpanded && hasDesc) {
|
|
534
|
+
const sep = document.createElement('div');
|
|
535
|
+
sep.style.cssText = `
|
|
536
|
+
border-top: 1px solid ${qColor}; opacity: 0.3;
|
|
537
|
+
`;
|
|
538
|
+
node.appendChild(sep);
|
|
539
|
+
|
|
540
|
+
const descDiv = document.createElement('div');
|
|
541
|
+
descDiv.style.cssText = `
|
|
542
|
+
padding: 6px 10px 8px; font-size: 11px; line-height: 1.6;
|
|
543
|
+
color: ${palette.textMuted};
|
|
544
|
+
`;
|
|
545
|
+
descDiv.innerHTML = renderDescriptionHtml(blip.description, palette);
|
|
546
|
+
node.appendChild(descDiv);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Hover: dim all other blips (radar + panel), scale up matching radar dot
|
|
550
|
+
node.addEventListener('mouseenter', () => {
|
|
551
|
+
dimExcept(rootContainer, ln);
|
|
552
|
+
// Scale up matching radar dot
|
|
553
|
+
rootContainer
|
|
554
|
+
.querySelectorAll<SVGElement>('svg [data-line-number]')
|
|
555
|
+
.forEach((el) => {
|
|
556
|
+
if (el.getAttribute('data-line-number') === ln) {
|
|
557
|
+
const bbox = (el as SVGGraphicsElement).getBBox?.();
|
|
558
|
+
if (bbox) {
|
|
559
|
+
const bx = bbox.x + bbox.width / 2;
|
|
560
|
+
const by = bbox.y + bbox.height / 2;
|
|
561
|
+
el.setAttribute(
|
|
562
|
+
'transform',
|
|
563
|
+
`translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
node.addEventListener('mouseleave', () => {
|
|
570
|
+
clearDim(rootContainer);
|
|
571
|
+
rootContainer
|
|
572
|
+
.querySelectorAll<SVGElement>('svg [data-line-number]')
|
|
573
|
+
.forEach((el) => {
|
|
574
|
+
el.removeAttribute('transform');
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Click: accordion toggle + persistent dim
|
|
579
|
+
node.addEventListener('click', (event) => {
|
|
580
|
+
event.stopPropagation();
|
|
581
|
+
if (hasDesc) {
|
|
582
|
+
const wasExpanded = expandedLineNum === ln;
|
|
583
|
+
expandedLineNum = wasExpanded ? null : ln;
|
|
584
|
+
render();
|
|
585
|
+
if (!wasExpanded) {
|
|
586
|
+
// Dim others after re-render
|
|
587
|
+
requestAnimationFrame(() => {
|
|
588
|
+
dimExcept(rootContainer, ln);
|
|
589
|
+
// Scale up matching radar dot
|
|
590
|
+
rootContainer
|
|
591
|
+
.querySelectorAll<SVGElement>('svg [data-line-number]')
|
|
592
|
+
.forEach((el) => {
|
|
593
|
+
if (el.getAttribute('data-line-number') === ln) {
|
|
594
|
+
const bbox = (el as SVGGraphicsElement).getBBox?.();
|
|
595
|
+
if (bbox) {
|
|
596
|
+
const cbx = bbox.x + bbox.width / 2;
|
|
597
|
+
const cby = bbox.y + bbox.height / 2;
|
|
598
|
+
el.setAttribute(
|
|
599
|
+
'transform',
|
|
600
|
+
`translate(${cbx},${cby}) scale(1.5) translate(${-cbx},${-cby})`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
} else {
|
|
607
|
+
// Collapsed — clear all
|
|
608
|
+
clearDim(rootContainer);
|
|
609
|
+
rootContainer
|
|
610
|
+
.querySelectorAll<SVGElement>('svg [data-line-number]')
|
|
611
|
+
.forEach((el) => el.removeAttribute('transform'));
|
|
612
|
+
}
|
|
613
|
+
} else if (onClickItem) {
|
|
614
|
+
onClickItem(blip.lineNumber);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
ringGroup.appendChild(node);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
render();
|
|
624
|
+
|
|
625
|
+
// Click on empty panel space → collapse and clear
|
|
626
|
+
panel.addEventListener('click', () => {
|
|
627
|
+
if (expandedLineNum) {
|
|
628
|
+
expandedLineNum = null;
|
|
629
|
+
render();
|
|
630
|
+
clearDim(rootContainer);
|
|
631
|
+
rootContainer
|
|
632
|
+
.querySelectorAll<SVGElement>('svg [data-line-number]')
|
|
633
|
+
.forEach((el) => el.removeAttribute('transform'));
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Return a toggle function for external callers (radar blip clicks)
|
|
638
|
+
return (lineNumber: number) => {
|
|
639
|
+
const ln = String(lineNumber);
|
|
640
|
+
const blip = quadrant.blips.find((b) => String(b.lineNumber) === ln);
|
|
641
|
+
if (blip && blip.description.length > 0) {
|
|
642
|
+
const wasExpanded = expandedLineNum === ln;
|
|
643
|
+
expandedLineNum = wasExpanded ? null : ln;
|
|
644
|
+
render();
|
|
645
|
+
const node = panel.querySelector(`[data-line-number="${ln}"]`);
|
|
646
|
+
if (node) node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
647
|
+
if (!wasExpanded) {
|
|
648
|
+
requestAnimationFrame(() => dimExcept(rootContainer, ln));
|
|
649
|
+
} else {
|
|
650
|
+
clearDim(rootContainer);
|
|
651
|
+
rootContainer
|
|
652
|
+
.querySelectorAll<SVGElement>('svg [data-line-number]')
|
|
653
|
+
.forEach((el) => el.removeAttribute('transform'));
|
|
654
|
+
}
|
|
655
|
+
} else if (onClickItem) {
|
|
656
|
+
onClickItem(lineNumber);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ============================================================
|
|
662
|
+
// Description HTML Rendering (with markdown)
|
|
663
|
+
// ============================================================
|
|
664
|
+
|
|
665
|
+
function renderDescriptionHtml(
|
|
666
|
+
lines: string[],
|
|
667
|
+
palette: PaletteColors
|
|
668
|
+
): string {
|
|
669
|
+
// Join prose lines into paragraphs, keep bullets separate
|
|
670
|
+
const paragraphs = joinParagraphs(lines);
|
|
671
|
+
let html = '';
|
|
672
|
+
|
|
673
|
+
for (const para of paragraphs) {
|
|
674
|
+
const trimmed = para.trim();
|
|
675
|
+
const isBullet = /^[-*•]\s+/.test(trimmed);
|
|
676
|
+
const content = isBullet ? trimmed.replace(/^[-*•]\s+/, '') : trimmed;
|
|
677
|
+
const rendered = renderInlineMarkdownHtml(content, palette);
|
|
678
|
+
|
|
679
|
+
if (isBullet) {
|
|
680
|
+
html += `<div style="padding-left: 14px; text-indent: -10px; margin: 2px 0;">• ${rendered}</div>`;
|
|
681
|
+
} else {
|
|
682
|
+
html += `<div style="margin: 4px 0;">${rendered}</div>`;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return html;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function joinParagraphs(lines: string[]): string[] {
|
|
690
|
+
const result: string[] = [];
|
|
691
|
+
let currentPara = '';
|
|
692
|
+
|
|
693
|
+
for (const line of lines) {
|
|
694
|
+
const trimmed = line.trim();
|
|
695
|
+
const isBullet = /^[-*•]\s+/.test(trimmed);
|
|
696
|
+
|
|
697
|
+
if (isBullet) {
|
|
698
|
+
if (currentPara) {
|
|
699
|
+
result.push(currentPara);
|
|
700
|
+
currentPara = '';
|
|
701
|
+
}
|
|
702
|
+
result.push(trimmed);
|
|
703
|
+
} else if (!trimmed) {
|
|
704
|
+
if (currentPara) {
|
|
705
|
+
result.push(currentPara);
|
|
706
|
+
currentPara = '';
|
|
707
|
+
}
|
|
708
|
+
} else {
|
|
709
|
+
currentPara = currentPara ? `${currentPara} ${trimmed}` : trimmed;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (currentPara) result.push(currentPara);
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function renderInlineMarkdownHtml(
|
|
718
|
+
text: string,
|
|
719
|
+
palette: PaletteColors
|
|
720
|
+
): string {
|
|
721
|
+
const spans = parseInlineMarkdown(text);
|
|
722
|
+
let html = '';
|
|
723
|
+
for (const span of spans) {
|
|
724
|
+
let t = escapeHtml(span.text);
|
|
725
|
+
if (span.bold) t = `<strong>${t}</strong>`;
|
|
726
|
+
if (span.italic) t = `<em>${t}</em>`;
|
|
727
|
+
if (span.code)
|
|
728
|
+
t = `<code style="background:${palette.surface}; padding: 1px 4px; border-radius: 3px; font-size: 10px;">${t}</code>`;
|
|
729
|
+
if (span.href)
|
|
730
|
+
t = `<a href="${escapeHtml(span.href)}" target="_blank" rel="noopener" style="color: ${palette.primary ?? palette.text}; text-decoration: underline;">${t}</a>`;
|
|
731
|
+
html += t;
|
|
732
|
+
}
|
|
733
|
+
return html;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function escapeHtml(text: string): string {
|
|
737
|
+
return text
|
|
738
|
+
.replace(/&/g, '&')
|
|
739
|
+
.replace(/</g, '<')
|
|
740
|
+
.replace(/>/g, '>');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ============================================================
|
|
744
|
+
// Static Quadrant Export Renderer (all descriptions expanded, no interactivity)
|
|
745
|
+
// ============================================================
|
|
746
|
+
|
|
747
|
+
export function renderQuadrantFocusForExport(
|
|
748
|
+
container: HTMLDivElement,
|
|
749
|
+
parsed: ParsedTechRadar,
|
|
750
|
+
quadrantPosition: QuadrantPosition,
|
|
751
|
+
palette: PaletteColors,
|
|
752
|
+
isDark: boolean,
|
|
753
|
+
exportDims: { width: number; height: number }
|
|
754
|
+
): void {
|
|
755
|
+
const quadrant = parsed.quadrants.find(
|
|
756
|
+
(q) => q.position === quadrantPosition
|
|
757
|
+
);
|
|
758
|
+
if (!quadrant) return;
|
|
759
|
+
|
|
760
|
+
container.innerHTML = '';
|
|
761
|
+
|
|
762
|
+
const width = exportDims.width;
|
|
763
|
+
const height = exportDims.height;
|
|
764
|
+
const qColor = resolveQuadrantColor(
|
|
765
|
+
quadrant.position,
|
|
766
|
+
quadrant.color,
|
|
767
|
+
palette
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
// ── Title bar ──
|
|
771
|
+
const titleBar = document.createElement('div');
|
|
772
|
+
titleBar.style.cssText = `
|
|
773
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
774
|
+
padding: 12px 16px; font-family: ${FONT_FAMILY};
|
|
775
|
+
font-size: ${TITLE_FONT_SIZE + 2}px; background: ${palette.bg};
|
|
776
|
+
`;
|
|
777
|
+
|
|
778
|
+
const titleText = document.createElement('span');
|
|
779
|
+
titleText.textContent = parsed.title || 'Tech Radar';
|
|
780
|
+
titleText.style.cssText = `font-weight: bold; color: ${palette.text};`;
|
|
781
|
+
|
|
782
|
+
const sep = document.createElement('span');
|
|
783
|
+
sep.textContent = '›';
|
|
784
|
+
sep.style.color = palette.border;
|
|
785
|
+
|
|
786
|
+
const quadrantLabel = document.createElement('span');
|
|
787
|
+
quadrantLabel.textContent = quadrant.name;
|
|
788
|
+
quadrantLabel.style.cssText = `font-weight: bold; color: ${qColor};`;
|
|
789
|
+
|
|
790
|
+
titleBar.appendChild(titleText);
|
|
791
|
+
titleBar.appendChild(sep);
|
|
792
|
+
titleBar.appendChild(quadrantLabel);
|
|
793
|
+
container.appendChild(titleBar);
|
|
794
|
+
|
|
795
|
+
// ── Main layout: SVG radar (left) + HTML panel (right) ──
|
|
796
|
+
const mainLayout = document.createElement('div');
|
|
797
|
+
mainLayout.style.cssText = `
|
|
798
|
+
display: flex; flex-direction: row;
|
|
799
|
+
height: ${height - 48}px; background: ${palette.bg};
|
|
800
|
+
`;
|
|
801
|
+
container.appendChild(mainLayout);
|
|
802
|
+
|
|
803
|
+
// SVG container for quarter-circle (left 45%)
|
|
804
|
+
const svgContainer = document.createElement('div');
|
|
805
|
+
svgContainer.style.cssText = `width: 45%; min-width: 200px; flex-shrink: 0;`;
|
|
806
|
+
mainLayout.appendChild(svgContainer);
|
|
807
|
+
|
|
808
|
+
// HTML panel for blip listing (right 55%)
|
|
809
|
+
const panel = document.createElement('div');
|
|
810
|
+
panel.style.cssText = `
|
|
811
|
+
flex: 1; padding: 8px 16px;
|
|
812
|
+
font-family: ${FONT_FAMILY}; background: ${palette.bg};
|
|
813
|
+
`;
|
|
814
|
+
mainLayout.appendChild(panel);
|
|
815
|
+
|
|
816
|
+
// ── Render static HTML panel (all descriptions expanded) ──
|
|
817
|
+
renderStaticHtmlPanel(panel, parsed, quadrant, qColor, palette, isDark);
|
|
818
|
+
|
|
819
|
+
// ── Render quarter-circle SVG ──
|
|
820
|
+
const svgWidth = width * 0.45;
|
|
821
|
+
const svgHeight = height - 48;
|
|
822
|
+
|
|
823
|
+
const svg = d3Selection
|
|
824
|
+
.select(svgContainer)
|
|
825
|
+
.append('svg')
|
|
826
|
+
.attr('width', svgWidth)
|
|
827
|
+
.attr('height', svgHeight)
|
|
828
|
+
.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`)
|
|
829
|
+
.style('background', palette.bg);
|
|
830
|
+
|
|
831
|
+
renderQuarterCircleStatic(
|
|
832
|
+
svg,
|
|
833
|
+
parsed,
|
|
834
|
+
quadrant,
|
|
835
|
+
qColor,
|
|
836
|
+
palette,
|
|
837
|
+
isDark,
|
|
838
|
+
svgWidth,
|
|
839
|
+
svgHeight,
|
|
840
|
+
palette.border
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Render the quarter-circle SVG without any interactivity.
|
|
846
|
+
*/
|
|
847
|
+
function renderQuarterCircleStatic(
|
|
848
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
849
|
+
parsed: ParsedTechRadar,
|
|
850
|
+
quadrant: ParsedTechRadar['quadrants'][number],
|
|
851
|
+
qColor: string,
|
|
852
|
+
palette: PaletteColors,
|
|
853
|
+
isDark: boolean,
|
|
854
|
+
width: number,
|
|
855
|
+
height: number,
|
|
856
|
+
mutedColor: string
|
|
857
|
+
): void {
|
|
858
|
+
const padding = 8;
|
|
859
|
+
const size = Math.min(width - padding, height - padding);
|
|
860
|
+
const maxRadius = size * 0.95;
|
|
861
|
+
const ringCount = parsed.rings.length;
|
|
862
|
+
const ringBandWidth = maxRadius / ringCount;
|
|
863
|
+
|
|
864
|
+
const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
|
|
865
|
+
let cx: number, cy: number;
|
|
866
|
+
|
|
867
|
+
switch (quadrant.position) {
|
|
868
|
+
case 'top-right':
|
|
869
|
+
cx = padding;
|
|
870
|
+
cy = size + padding;
|
|
871
|
+
break;
|
|
872
|
+
case 'top-left':
|
|
873
|
+
cx = width - padding;
|
|
874
|
+
cy = size + padding;
|
|
875
|
+
break;
|
|
876
|
+
case 'bottom-left':
|
|
877
|
+
cx = width - padding;
|
|
878
|
+
cy = padding;
|
|
879
|
+
break;
|
|
880
|
+
case 'bottom-right':
|
|
881
|
+
cx = padding;
|
|
882
|
+
cy = padding;
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Ring arcs with zebra shading
|
|
887
|
+
const arcGen = (innerR: number, outerR: number) =>
|
|
888
|
+
`M${cx + outerR * Math.cos(startAngle)},${cy - outerR * Math.sin(startAngle)} A${outerR},${outerR} 0 0,0 ${cx + outerR * Math.cos(endAngle)},${cy - outerR * Math.sin(endAngle)} L${cx + innerR * Math.cos(endAngle)},${cy - innerR * Math.sin(endAngle)} A${innerR},${innerR} 0 0,1 ${cx + innerR * Math.cos(startAngle)},${cy - innerR * Math.sin(startAngle)} Z`;
|
|
889
|
+
|
|
890
|
+
for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
|
|
891
|
+
const innerR = ri * ringBandWidth;
|
|
892
|
+
const outerR = (ri + 1) * ringBandWidth;
|
|
893
|
+
const fillColor =
|
|
894
|
+
ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
|
|
895
|
+
|
|
896
|
+
svg
|
|
897
|
+
.append('path')
|
|
898
|
+
.attr('d', arcGen(innerR, outerR))
|
|
899
|
+
.attr('fill', fillColor)
|
|
900
|
+
.attr('stroke', mutedColor)
|
|
901
|
+
.attr('stroke-width', 0.5);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Ring labels along the arc edge
|
|
905
|
+
for (let ri = 0; ri < parsed.rings.length; ri++) {
|
|
906
|
+
const rCenter = (ri + 0.5) * ringBandWidth;
|
|
907
|
+
const midAngle = (startAngle + endAngle) / 2;
|
|
908
|
+
const labelX = cx + rCenter * Math.cos(midAngle);
|
|
909
|
+
const labelY = cy - rCenter * Math.sin(midAngle);
|
|
910
|
+
|
|
911
|
+
svg
|
|
912
|
+
.append('text')
|
|
913
|
+
.attr('x', labelX)
|
|
914
|
+
.attr('y', labelY)
|
|
915
|
+
.attr('text-anchor', 'middle')
|
|
916
|
+
.attr('dominant-baseline', 'central')
|
|
917
|
+
.attr('fill', palette.textMuted)
|
|
918
|
+
.attr('font-family', FONT_FAMILY)
|
|
919
|
+
.attr('font-size', 11)
|
|
920
|
+
.attr('font-weight', '600')
|
|
921
|
+
.attr('opacity', 0.5)
|
|
922
|
+
.text(parsed.rings[ri].name);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Blip dots
|
|
926
|
+
const ringOrder = parsed.rings.map((r) => r.name);
|
|
927
|
+
const angularPadding = 0.08;
|
|
928
|
+
const radialPadding = ringBandWidth * 0.12;
|
|
929
|
+
const usableArcStart = startAngle + angularPadding;
|
|
930
|
+
const usableArcEnd = endAngle - angularPadding;
|
|
931
|
+
const arcSpan = usableArcEnd - usableArcStart;
|
|
932
|
+
|
|
933
|
+
const blipsByRing = new Map<string, typeof quadrant.blips>();
|
|
934
|
+
for (const blip of quadrant.blips) {
|
|
935
|
+
const list = blipsByRing.get(blip.ring) ?? [];
|
|
936
|
+
list.push(blip);
|
|
937
|
+
blipsByRing.set(blip.ring, list);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
for (const [ringName, blips] of blipsByRing) {
|
|
941
|
+
const ringIndex = ringOrder.indexOf(ringName);
|
|
942
|
+
if (ringIndex < 0) continue;
|
|
943
|
+
const rInner = ringIndex * ringBandWidth + radialPadding;
|
|
944
|
+
const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
|
|
945
|
+
const rMid = (rInner + rOuter) / 2;
|
|
946
|
+
|
|
947
|
+
for (let bi = 0; bi < blips.length; bi++) {
|
|
948
|
+
const blip = blips[bi];
|
|
949
|
+
const angle =
|
|
950
|
+
blips.length === 1
|
|
951
|
+
? (usableArcStart + usableArcEnd) / 2
|
|
952
|
+
: usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
|
|
953
|
+
|
|
954
|
+
const radius =
|
|
955
|
+
blips.length <= 3
|
|
956
|
+
? rMid
|
|
957
|
+
: rInner +
|
|
958
|
+
BLIP_RADIUS +
|
|
959
|
+
((bi % 3) / 2) * (rOuter - rInner - BLIP_RADIUS * 2);
|
|
960
|
+
|
|
961
|
+
const bx = cx + radius * Math.cos(angle);
|
|
962
|
+
const by = cy - radius * Math.sin(angle);
|
|
963
|
+
|
|
964
|
+
const blipGroup = svg.append('g');
|
|
965
|
+
|
|
966
|
+
const angleToCenter = Math.atan2(cy - by, cx - bx);
|
|
967
|
+
renderTrendIndicator(
|
|
968
|
+
blipGroup,
|
|
969
|
+
blip.trend,
|
|
970
|
+
qColor,
|
|
971
|
+
bx,
|
|
972
|
+
by,
|
|
973
|
+
BLIP_RADIUS,
|
|
974
|
+
angleToCenter
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
blipGroup
|
|
978
|
+
.append('text')
|
|
979
|
+
.attr('x', bx)
|
|
980
|
+
.attr('y', by + 3)
|
|
981
|
+
.attr('text-anchor', 'middle')
|
|
982
|
+
.attr('fill', isDark ? '#000' : '#fff')
|
|
983
|
+
.attr('font-family', FONT_FAMILY)
|
|
984
|
+
.attr('font-size', BLIP_FONT_SIZE)
|
|
985
|
+
.attr('font-weight', 'bold')
|
|
986
|
+
.text(blip.globalNumber);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Render static HTML panel with all descriptions expanded (for export).
|
|
993
|
+
*/
|
|
994
|
+
function renderStaticHtmlPanel(
|
|
995
|
+
panel: HTMLElement,
|
|
996
|
+
parsed: ParsedTechRadar,
|
|
997
|
+
quadrant: ParsedTechRadar['quadrants'][number],
|
|
998
|
+
qColor: string,
|
|
999
|
+
palette: PaletteColors,
|
|
1000
|
+
isDark: boolean
|
|
1001
|
+
): void {
|
|
1002
|
+
const ringOrder = parsed.rings.map((r) => r.name);
|
|
1003
|
+
const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
|
|
1004
|
+
|
|
1005
|
+
for (const ringName of ringOrder) {
|
|
1006
|
+
const blips = quadrant.blips.filter((b) => b.ring === ringName);
|
|
1007
|
+
if (blips.length === 0) continue;
|
|
1008
|
+
|
|
1009
|
+
// Ring group container
|
|
1010
|
+
const ringGroup = document.createElement('div');
|
|
1011
|
+
ringGroup.style.cssText = `
|
|
1012
|
+
background: ${palette.surface};
|
|
1013
|
+
border-radius: 8px;
|
|
1014
|
+
padding: 10px;
|
|
1015
|
+
margin-bottom: 12px;
|
|
1016
|
+
`;
|
|
1017
|
+
|
|
1018
|
+
// Ring header
|
|
1019
|
+
const header = document.createElement('div');
|
|
1020
|
+
header.style.cssText = `
|
|
1021
|
+
font-size: 13px; font-weight: 700; color: ${palette.textMuted};
|
|
1022
|
+
margin-bottom: 8px;
|
|
1023
|
+
`;
|
|
1024
|
+
header.textContent = ringName;
|
|
1025
|
+
ringGroup.appendChild(header);
|
|
1026
|
+
|
|
1027
|
+
panel.appendChild(ringGroup);
|
|
1028
|
+
|
|
1029
|
+
for (const blip of blips) {
|
|
1030
|
+
const hasDesc = blip.description.length > 0;
|
|
1031
|
+
|
|
1032
|
+
const node = document.createElement('div');
|
|
1033
|
+
node.style.cssText = `
|
|
1034
|
+
background: ${fillColor}; border: 1.5px solid ${qColor};
|
|
1035
|
+
border-radius: 6px; margin-bottom: 6px;
|
|
1036
|
+
`;
|
|
1037
|
+
|
|
1038
|
+
// Title row
|
|
1039
|
+
const titleRow = document.createElement('div');
|
|
1040
|
+
titleRow.style.cssText = `
|
|
1041
|
+
display: flex; align-items: center; gap: 8px;
|
|
1042
|
+
padding: 6px 10px; min-height: 28px;
|
|
1043
|
+
`;
|
|
1044
|
+
|
|
1045
|
+
// Mini SVG blip indicator
|
|
1046
|
+
const indicatorSvg = document.createElementNS(
|
|
1047
|
+
'http://www.w3.org/2000/svg',
|
|
1048
|
+
'svg'
|
|
1049
|
+
);
|
|
1050
|
+
indicatorSvg.setAttribute('width', '26');
|
|
1051
|
+
indicatorSvg.setAttribute('height', '26');
|
|
1052
|
+
indicatorSvg.style.flexShrink = '0';
|
|
1053
|
+
const indicatorG = d3Selection
|
|
1054
|
+
.select(indicatorSvg)
|
|
1055
|
+
.append('g') as d3Selection.Selection<
|
|
1056
|
+
SVGGElement,
|
|
1057
|
+
unknown,
|
|
1058
|
+
null,
|
|
1059
|
+
undefined
|
|
1060
|
+
>;
|
|
1061
|
+
renderTrendIndicator(
|
|
1062
|
+
indicatorG,
|
|
1063
|
+
blip.trend,
|
|
1064
|
+
qColor,
|
|
1065
|
+
13,
|
|
1066
|
+
13,
|
|
1067
|
+
10,
|
|
1068
|
+
-Math.PI / 2
|
|
1069
|
+
);
|
|
1070
|
+
d3Selection
|
|
1071
|
+
.select(indicatorSvg)
|
|
1072
|
+
.append('text')
|
|
1073
|
+
.attr('x', 13)
|
|
1074
|
+
.attr('y', 16)
|
|
1075
|
+
.attr('text-anchor', 'middle')
|
|
1076
|
+
.attr('fill', isDark ? '#000' : '#fff')
|
|
1077
|
+
.attr('font-family', FONT_FAMILY)
|
|
1078
|
+
.attr('font-size', 9)
|
|
1079
|
+
.attr('font-weight', 'bold')
|
|
1080
|
+
.text(blip.globalNumber);
|
|
1081
|
+
titleRow.appendChild(indicatorSvg);
|
|
1082
|
+
|
|
1083
|
+
// Name
|
|
1084
|
+
const name = document.createElement('span');
|
|
1085
|
+
name.textContent = blip.name;
|
|
1086
|
+
name.style.cssText = `
|
|
1087
|
+
flex: 1; font-size: 12px; font-weight: 600;
|
|
1088
|
+
color: ${palette.text};
|
|
1089
|
+
`;
|
|
1090
|
+
titleRow.appendChild(name);
|
|
1091
|
+
|
|
1092
|
+
node.appendChild(titleRow);
|
|
1093
|
+
|
|
1094
|
+
// Description (always expanded for export)
|
|
1095
|
+
if (hasDesc) {
|
|
1096
|
+
const sepDiv = document.createElement('div');
|
|
1097
|
+
sepDiv.style.cssText = `border-top: 1px solid ${qColor}; opacity: 0.3;`;
|
|
1098
|
+
node.appendChild(sepDiv);
|
|
1099
|
+
|
|
1100
|
+
const descDiv = document.createElement('div');
|
|
1101
|
+
descDiv.style.cssText = `
|
|
1102
|
+
padding: 6px 10px 8px; font-size: 11px; line-height: 1.6;
|
|
1103
|
+
color: ${palette.textMuted};
|
|
1104
|
+
`;
|
|
1105
|
+
descDiv.innerHTML = renderDescriptionHtml(blip.description, palette);
|
|
1106
|
+
node.appendChild(descDiv);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
ringGroup.appendChild(node);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|