pptx-angular-viewer 1.1.15 → 1.1.16

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.
@@ -11833,6 +11833,1251 @@ function applyResize(start, handle, dx, dy, min = MIN_RESIZE) {
11833
11833
  return { x, y, width, height };
11834
11834
  }
11835
11835
 
11836
+ /**
11837
+ * View-model builders for combo and stock chart kinds.
11838
+ *
11839
+ * Ported from:
11840
+ * packages/react/src/viewer/utils/chart-waterfall-combo.tsx (renderComboChart)
11841
+ * packages/react/src/viewer/utils/chart-stock.tsx (renderStockChart)
11842
+ *
11843
+ * All functions here are pure TypeScript with zero Angular dependencies.
11844
+ * The component consumes a single `ChartViewModel` (same contract as the
11845
+ * helpers in chart-renderer-helpers.ts) that is the projection of a
11846
+ * `ChartPptxElement` -> SVG primitives.
11847
+ *
11848
+ * Combo charts:
11849
+ * series[0] → bar/column rectangles (one per category)
11850
+ * series[1…N] → line + dots (one polyline + N circles per series)
11851
+ *
11852
+ * Stock charts (HLC / OHLC candlesticks):
11853
+ * 3-series HLC → series: High, Low, Close
11854
+ * 4-series OHLC → series: Open, High, Low, Close
11855
+ * Per candle: a vertical wick line (high–low) + a body rect (open–close).
11856
+ * Body is green when close ≥ open, red otherwise.
11857
+ *
11858
+ * @module chart-combo-stock
11859
+ */
11860
+ // ─────────────────────────────────────────────────────────────────────────────
11861
+ // Candle colours (stock chart)
11862
+ // ─────────────────────────────────────────────────────────────────────────────
11863
+ const CANDLE_UP_FILL = '#22c55e';
11864
+ const CANDLE_DOWN_FILL = '#ef4444';
11865
+ const CANDLE_WICK_COLOR = '#334155';
11866
+ // ─────────────────────────────────────────────────────────────────────────────
11867
+ // Combo chart
11868
+ // ─────────────────────────────────────────────────────────────────────────────
11869
+ /**
11870
+ * Build a `ChartViewModel` for a combo chart (bar + line overlay).
11871
+ *
11872
+ * Convention (mirrors the React renderer):
11873
+ * - `chartData.series[0]` → rendered as clustered bar columns
11874
+ * - `chartData.series[1…N]` → rendered as line series with dot markers
11875
+ *
11876
+ * A single shared value-axis range is computed across ALL series so that the
11877
+ * bar and line series share the same Y scale. The category-axis tick style is
11878
+ * "bar" (evenly spaced groups with no extra edge padding).
11879
+ *
11880
+ * @param element - The chart element providing width/height.
11881
+ * @param chartData - Parsed chart data including series and style.
11882
+ * @param categoryLabels - Ordered category axis labels.
11883
+ * @returns A fully assembled `ChartViewModel` ready for the template.
11884
+ */
11885
+ function buildComboViewModel(element, chartData, categoryLabels) {
11886
+ const layout = computePlotLayout(element.width, element.height, chartData, true);
11887
+ const catCount = Math.max(categoryLabels.length, 1);
11888
+ // Single shared range across all series so bar and line share Y scale.
11889
+ const range = computeValueRange(chartData.series);
11890
+ const { gridlines, axisLabels } = buildGridlinesAndLabels(range, layout);
11891
+ const zeroLine = buildZeroLine(range, layout);
11892
+ const catLabels = buildCategoryLabels(categoryLabels, layout, 'bar');
11893
+ const legendPos = chartData.style?.legendPosition ?? 'b';
11894
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
11895
+ const primitives = [];
11896
+ const dataLabels = [];
11897
+ // ── Bar series (series[0]) ──────────────────────────────────────────────
11898
+ const barSeries = chartData.series.slice(0, 1);
11899
+ if (barSeries.length > 0) {
11900
+ const barRects = computeBarRects(barSeries, catCount, layout, range, chartData.colorPalette);
11901
+ for (const r of barRects) {
11902
+ primitives.push({
11903
+ kind: 'rect',
11904
+ x: r.x,
11905
+ y: r.y,
11906
+ w: r.w,
11907
+ h: r.h,
11908
+ fill: r.fill,
11909
+ rx: 1,
11910
+ });
11911
+ }
11912
+ if (chartData.style?.hasDataLabels && barSeries[0]) {
11913
+ const barGroupWidth = layout.plotWidth / catCount;
11914
+ const singleBarWidth = barGroupWidth * 0.7;
11915
+ const groupOffset = (barGroupWidth - singleBarWidth) / 2;
11916
+ barSeries[0].values.forEach((val, vi) => {
11917
+ const x = layout.plotLeft + barGroupWidth * vi + groupOffset + singleBarWidth / 2;
11918
+ const zeroY = valueToY(0, range, layout.plotTop, layout.plotBottom);
11919
+ const valY = valueToY(val, range, layout.plotTop, layout.plotBottom);
11920
+ const labelY = val >= 0 ? Math.min(zeroY, valY) - 4 : Math.max(zeroY, valY) + 10;
11921
+ dataLabels.push({
11922
+ kind: 'text',
11923
+ x,
11924
+ y: labelY,
11925
+ text: formatAxisValue(val),
11926
+ fontSize: 7,
11927
+ fill: '#334155',
11928
+ textAnchor: 'middle',
11929
+ });
11930
+ });
11931
+ }
11932
+ }
11933
+ // ── Line series (series[1…N]) ───────────────────────────────────────────
11934
+ // Line X positions are centred within each bar group for visual alignment.
11935
+ const barGroupWidth = layout.plotWidth / catCount;
11936
+ const lineSeries = chartData.series.slice(1);
11937
+ lineSeries.forEach((series, si) => {
11938
+ if (series.values.length === 0) {
11939
+ return;
11940
+ }
11941
+ const seriesIdx = si + 1; // offset past the bar series
11942
+ const c = seriesColor(series, seriesIdx, chartData.colorPalette);
11943
+ // Build points centred within each bar group to align with bar midpoints.
11944
+ const pts = series.values.map((val, vi) => ({
11945
+ x: layout.plotLeft + barGroupWidth * vi + barGroupWidth / 2,
11946
+ y: valueToY(val, range, layout.plotTop, layout.plotBottom),
11947
+ }));
11948
+ const pointsStr = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ');
11949
+ primitives.push({
11950
+ kind: 'polyline',
11951
+ points: pointsStr,
11952
+ stroke: c,
11953
+ strokeWidth: 2.4,
11954
+ fill: 'none',
11955
+ });
11956
+ for (const pt of pts) {
11957
+ primitives.push({
11958
+ kind: 'circle',
11959
+ cx: pt.x,
11960
+ cy: pt.y,
11961
+ r: 2.5,
11962
+ fill: c,
11963
+ });
11964
+ }
11965
+ if (chartData.style?.hasDataLabels) {
11966
+ series.values.forEach((val, vi) => {
11967
+ const pt = pts[vi];
11968
+ if (!pt) {
11969
+ return;
11970
+ }
11971
+ dataLabels.push({
11972
+ kind: 'text',
11973
+ x: pt.x,
11974
+ y: pt.y - 7,
11975
+ text: formatAxisValue(val),
11976
+ fontSize: 7,
11977
+ fill: '#334155',
11978
+ textAnchor: 'middle',
11979
+ });
11980
+ });
11981
+ }
11982
+ });
11983
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
11984
+ return {
11985
+ svgWidth: layout.svgWidth,
11986
+ svgHeight: layout.svgHeight,
11987
+ title,
11988
+ titleX: layout.svgWidth / 2,
11989
+ titleY: 12,
11990
+ gridlines,
11991
+ axisLabels,
11992
+ zeroLine,
11993
+ categoryLabels: catLabels,
11994
+ primitives,
11995
+ dataLabels,
11996
+ legend: chartData.style?.hasLegend ? legend : [],
11997
+ legendX,
11998
+ legendY,
11999
+ legendAnchor,
12000
+ };
12001
+ }
12002
+ // ─────────────────────────────────────────────────────────────────────────────
12003
+ // Stock chart
12004
+ // ─────────────────────────────────────────────────────────────────────────────
12005
+ /**
12006
+ * Build a `ChartViewModel` for a stock (HLC / OHLC) candlestick chart.
12007
+ *
12008
+ * Series layout convention (mirrors the React renderer):
12009
+ * 3-series: series[0] = High, series[1] = Low, series[2] = Close
12010
+ * 4-series: series[0] = Open, series[1] = High, series[2] = Low, series[3] = Close
12011
+ *
12012
+ * Per data point the builder emits:
12013
+ * - An `SvgLine` for the high-to-low wick (vertical).
12014
+ * - An `SvgRect` for the open-to-close candle body.
12015
+ *
12016
+ * Candle body is green (#22c55e) when close ≥ open, red (#ef4444) otherwise.
12017
+ * When no open series is present the close value is used as the open (HLC
12018
+ * mode), causing all candles to show as a coloured body between close and the
12019
+ * previously computed running close – but for simplicity, with no open we set
12020
+ * open = low value, matching PowerPoint's own HLC rendering heuristic.
12021
+ *
12022
+ * @param element - The chart element providing width/height.
12023
+ * @param chartData - Parsed chart data including series and style.
12024
+ * @param categoryLabels - Ordered category axis labels.
12025
+ * @returns A fully assembled `ChartViewModel` ready for the template.
12026
+ */
12027
+ function buildStockViewModel(element, chartData, categoryLabels) {
12028
+ const layout = computePlotLayout(element.width, element.height, chartData, true);
12029
+ const catCount = Math.max(categoryLabels.length, 1);
12030
+ const range = computeValueRange(chartData.series);
12031
+ const { gridlines, axisLabels } = buildGridlinesAndLabels(range, layout);
12032
+ const zeroLine = buildZeroLine(range, layout);
12033
+ const catLabels = buildCategoryLabels(categoryLabels, layout, 'bar');
12034
+ const legendPos = chartData.style?.legendPosition ?? 'b';
12035
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
12036
+ // ── Resolve OHLC series slots ──────────────────────────────────────────
12037
+ const hasFour = chartData.series.length >= 4;
12038
+ const openSeries = hasFour ? chartData.series[0] : undefined;
12039
+ const highSeries = chartData.series[hasFour ? 1 : 0];
12040
+ const lowSeries = chartData.series[hasFour ? 2 : 1];
12041
+ const closeSeries = chartData.series[hasFour ? 3 : 2];
12042
+ const primitives = [];
12043
+ const dataLabels = [];
12044
+ if (highSeries && lowSeries && closeSeries) {
12045
+ const barGroupWidth = layout.plotWidth / catCount;
12046
+ const candleWidth = barGroupWidth * 0.5;
12047
+ for (let ci = 0; ci < catCount; ci++) {
12048
+ const high = highSeries.values[ci] ?? 0;
12049
+ const low = lowSeries.values[ci] ?? 0;
12050
+ const open = openSeries ? (openSeries.values[ci] ?? low) : low;
12051
+ const close = closeSeries.values[ci] ?? high;
12052
+ const isUp = close >= open;
12053
+ const cx = layout.plotLeft + barGroupWidth * ci + barGroupWidth / 2;
12054
+ const highY = valueToY(high, range, layout.plotTop, layout.plotBottom);
12055
+ const lowY = valueToY(low, range, layout.plotTop, layout.plotBottom);
12056
+ const openY = valueToY(open, range, layout.plotTop, layout.plotBottom);
12057
+ const closeY = valueToY(close, range, layout.plotTop, layout.plotBottom);
12058
+ // Wick: vertical line from high to low.
12059
+ primitives.push({
12060
+ kind: 'line',
12061
+ x1: cx,
12062
+ y1: highY,
12063
+ x2: cx,
12064
+ y2: lowY,
12065
+ stroke: CANDLE_WICK_COLOR,
12066
+ strokeWidth: 1,
12067
+ });
12068
+ // Body: rect from open to close.
12069
+ const bodyTop = Math.min(openY, closeY);
12070
+ const bodyHeight = Math.max(Math.abs(openY - closeY), 1);
12071
+ primitives.push({
12072
+ kind: 'rect',
12073
+ x: cx - candleWidth / 2,
12074
+ y: bodyTop,
12075
+ w: candleWidth,
12076
+ h: bodyHeight,
12077
+ fill: isUp ? CANDLE_UP_FILL : CANDLE_DOWN_FILL,
12078
+ rx: 1,
12079
+ });
12080
+ if (chartData.style?.hasDataLabels) {
12081
+ dataLabels.push({
12082
+ kind: 'text',
12083
+ x: cx,
12084
+ y: highY - 4,
12085
+ text: formatAxisValue(close),
12086
+ fontSize: 7,
12087
+ fill: '#334155',
12088
+ textAnchor: 'middle',
12089
+ });
12090
+ }
12091
+ }
12092
+ }
12093
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
12094
+ return {
12095
+ svgWidth: layout.svgWidth,
12096
+ svgHeight: layout.svgHeight,
12097
+ title,
12098
+ titleX: layout.svgWidth / 2,
12099
+ titleY: 12,
12100
+ gridlines,
12101
+ axisLabels,
12102
+ zeroLine,
12103
+ categoryLabels: catLabels,
12104
+ primitives,
12105
+ dataLabels,
12106
+ legend: chartData.style?.hasLegend ? legend : [],
12107
+ legendX,
12108
+ legendY,
12109
+ legendAnchor,
12110
+ };
12111
+ }
12112
+
12113
+ /**
12114
+ * View-model builders for surface and treemap chart kinds.
12115
+ *
12116
+ * Ported from:
12117
+ * packages/react/src/viewer/utils/chart-surface-treemap.tsx (surface + treemap)
12118
+ *
12119
+ * Produces a `ChartViewModel` (SVG primitives only, zero Angular dependencies)
12120
+ * that the Angular ChartRendererComponent template iterates over.
12121
+ *
12122
+ * Surface – isometric projection when the grid has ≥2 series and ≥2 categories,
12123
+ * flat colour-mapped grid otherwise.
12124
+ * Treemap – slice-and-dice rectangles sorted largest-first with inline labels.
12125
+ *
12126
+ * @module chart-surface-treemap
12127
+ */
12128
+ // ─────────────────────────────────────────────────────────────────────────────
12129
+ // Isometric projection constants (mirrors React's ISO_COS30 / ISO_SIN30)
12130
+ // ─────────────────────────────────────────────────────────────────────────────
12131
+ const ISO_COS30 = Math.cos(Math.PI / 6);
12132
+ const ISO_SIN30 = Math.sin(Math.PI / 6);
12133
+ /** Project a 3-D (x, y, z) grid coordinate to 2-D isometric screen space. */
12134
+ function isoProject(x, y, z) {
12135
+ return {
12136
+ screenX: (x - y) * ISO_COS30,
12137
+ screenY: (x + y) * ISO_SIN30 - z,
12138
+ };
12139
+ }
12140
+ /** Map a normalised value t in [0..1] to a surface colour ramp (blue→green→red). */
12141
+ function surfaceColor(t) {
12142
+ return {
12143
+ r: Math.round(30 + 200 * t),
12144
+ g: Math.round(80 + 100 * (1 - Math.abs(t - 0.5) * 2)),
12145
+ b: Math.round(200 * (1 - t) + 30),
12146
+ };
12147
+ }
12148
+ /** Darken an rgb triplet by a factor in [0..1]. */
12149
+ function darkenRgb(r, g, b, factor) {
12150
+ return `rgb(${Math.round(r * factor)},${Math.round(g * factor)},${Math.round(b * factor)})`;
12151
+ }
12152
+ // ─────────────────────────────────────────────────────────────────────────────
12153
+ // Shared empty-chrome helper
12154
+ // ─────────────────────────────────────────────────────────────────────────────
12155
+ function emptyChrome() {
12156
+ return {
12157
+ gridlines: [],
12158
+ axisLabels: [],
12159
+ zeroLine: undefined,
12160
+ categoryLabels: [],
12161
+ dataLabels: [],
12162
+ };
12163
+ }
12164
+ // ─────────────────────────────────────────────────────────────────────────────
12165
+ // Surface: isometric renderer (≥2 series, ≥2 categories)
12166
+ // ─────────────────────────────────────────────────────────────────────────────
12167
+ function buildIsometricSurfaceViewModel(element, chartData, categoryLabels) {
12168
+ const layout = computePlotLayout(element.width, element.height, chartData, false);
12169
+ const range = computeValueRange(chartData.series);
12170
+ const catCount = Math.max(categoryLabels.length, 1);
12171
+ const seriesCount = chartData.series.length;
12172
+ // Grid cell count (vertices = cells + 1 in each dimension).
12173
+ const cols = Math.max(catCount - 1, 1);
12174
+ const rows = Math.max(seriesCount - 1, 1);
12175
+ const gridSpan = cols + rows;
12176
+ const cellByWidth = (layout.plotWidth * 0.9) / (gridSpan * ISO_COS30);
12177
+ const cellByHeight = (layout.plotHeight * 0.65) / (gridSpan * ISO_SIN30);
12178
+ const cellSize = Math.max(Math.min(cellByWidth, cellByHeight), 0.5);
12179
+ const zHeadroom = layout.plotHeight * 0.3;
12180
+ const zScale = range.span > 0 ? zHeadroom : 0;
12181
+ const normValue = (r, c) => {
12182
+ const ri = Math.min(r, seriesCount - 1);
12183
+ const ci = Math.min(c, catCount - 1);
12184
+ const val = chartData.series[ri]?.values[ci] ?? 0;
12185
+ return range.span > 0 ? (val - range.min) / range.span : 0;
12186
+ };
12187
+ // Compute isometric bounding box to centre the projection.
12188
+ const projectedPoints = [];
12189
+ for (let r = 0; r <= rows; r++) {
12190
+ for (let c = 0; c <= cols; c++) {
12191
+ projectedPoints.push(isoProject(c * cellSize, r * cellSize, normValue(r, c) * zScale));
12192
+ }
12193
+ }
12194
+ const minSX = Math.min(...projectedPoints.map((p) => p.screenX));
12195
+ const maxSX = Math.max(...projectedPoints.map((p) => p.screenX));
12196
+ const minSY = Math.min(...projectedPoints.map((p) => p.screenY));
12197
+ const maxSY = Math.max(...projectedPoints.map((p) => p.screenY));
12198
+ const projW = maxSX - minSX;
12199
+ const projH = maxSY - minSY;
12200
+ const offsetX = layout.plotLeft + layout.plotWidth / 2 - (minSX + projW / 2);
12201
+ const offsetY = layout.plotTop + layout.plotHeight / 2 - (minSY + projH / 2);
12202
+ const cells = [];
12203
+ for (let r = 0; r < rows; r++) {
12204
+ for (let c = 0; c < cols; c++) {
12205
+ cells.push({ row: r, col: c, depth: r + c });
12206
+ }
12207
+ }
12208
+ cells.sort((a, b) => a.depth - b.depth);
12209
+ const primitives = [];
12210
+ for (const { row, col } of cells) {
12211
+ // Four corners of the isometric parallelogram.
12212
+ const corners = [
12213
+ [col, row],
12214
+ [col + 1, row],
12215
+ [col + 1, row + 1],
12216
+ [col, row + 1],
12217
+ ];
12218
+ const verts = corners.map(([c, r]) => {
12219
+ const nv = normValue(r, c);
12220
+ return isoProject(c * cellSize, r * cellSize, nv * zScale);
12221
+ });
12222
+ const avgT = (normValue(row, col) +
12223
+ normValue(row, col + 1) +
12224
+ normValue(row + 1, col + 1) +
12225
+ normValue(row + 1, col)) /
12226
+ 4;
12227
+ const { r, g, b } = surfaceColor(avgT);
12228
+ const points = verts
12229
+ .map((v) => `${(v.screenX + offsetX).toFixed(2)},${(v.screenY + offsetY).toFixed(2)}`)
12230
+ .join(' ');
12231
+ // Face fill polygon.
12232
+ primitives.push({
12233
+ kind: 'polygon',
12234
+ points,
12235
+ fill: `rgb(${r},${g},${b})`,
12236
+ stroke: 'none',
12237
+ strokeWidth: 0,
12238
+ opacity: 0.9,
12239
+ });
12240
+ // Subtle edge overlay for depth perception.
12241
+ primitives.push({
12242
+ kind: 'polygon',
12243
+ points,
12244
+ fill: 'none',
12245
+ stroke: darkenRgb(r, g, b, 0.6),
12246
+ strokeWidth: 0.5,
12247
+ opacity: 0.7,
12248
+ });
12249
+ }
12250
+ const legendPos = chartData.style?.legendPosition ?? 'b';
12251
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
12252
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
12253
+ return {
12254
+ svgWidth: layout.svgWidth,
12255
+ svgHeight: layout.svgHeight,
12256
+ title,
12257
+ titleX: layout.svgWidth / 2,
12258
+ titleY: 14,
12259
+ ...emptyChrome(),
12260
+ primitives,
12261
+ legend: chartData.style?.hasLegend ? legend : [],
12262
+ legendX,
12263
+ legendY,
12264
+ legendAnchor,
12265
+ };
12266
+ }
12267
+ // ─────────────────────────────────────────────────────────────────────────────
12268
+ // Surface: flat colour-mapped grid (fallback)
12269
+ // ─────────────────────────────────────────────────────────────────────────────
12270
+ function buildFlatSurfaceViewModel(element, chartData, categoryLabels) {
12271
+ const layout = computePlotLayout(element.width, element.height, chartData, false);
12272
+ const range = computeValueRange(chartData.series);
12273
+ const catCount = Math.max(categoryLabels.length, 1);
12274
+ const seriesCount = chartData.series.length;
12275
+ const cellW = layout.plotWidth / Math.max(catCount - 1, 1);
12276
+ const cellH = layout.plotHeight / Math.max(seriesCount - 1, 1);
12277
+ const primitives = [];
12278
+ for (let si = 0; si < seriesCount; si++) {
12279
+ for (let ci = 0; ci < catCount; ci++) {
12280
+ const val = chartData.series[si]?.values[ci] ?? 0;
12281
+ const t = range.span > 0 ? (val - range.min) / range.span : 0;
12282
+ const { r, g, b } = surfaceColor(t);
12283
+ primitives.push({
12284
+ kind: 'rect',
12285
+ x: layout.plotLeft + ci * cellW,
12286
+ y: layout.plotTop + si * cellH,
12287
+ w: cellW + 0.5,
12288
+ h: cellH + 0.5,
12289
+ fill: `rgb(${r},${g},${b})`,
12290
+ opacity: 0.85,
12291
+ });
12292
+ }
12293
+ }
12294
+ const legendPos = chartData.style?.legendPosition ?? 'b';
12295
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
12296
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
12297
+ return {
12298
+ svgWidth: layout.svgWidth,
12299
+ svgHeight: layout.svgHeight,
12300
+ title,
12301
+ titleX: layout.svgWidth / 2,
12302
+ titleY: 14,
12303
+ ...emptyChrome(),
12304
+ primitives,
12305
+ legend: chartData.style?.hasLegend ? legend : [],
12306
+ legendX,
12307
+ legendY,
12308
+ legendAnchor,
12309
+ };
12310
+ }
12311
+ // ─────────────────────────────────────────────────────────────────────────────
12312
+ // Public: buildSurfaceViewModel
12313
+ // ─────────────────────────────────────────────────────────────────────────────
12314
+ /**
12315
+ * Build the view-model for a surface chart.
12316
+ *
12317
+ * Renders an isometric 3-D-like projection when the grid has ≥2 series and
12318
+ * ≥2 categories; falls back to a flat colour-mapped grid otherwise.
12319
+ * Mirrors `renderSurfaceChart` / `renderIsometricSurfaceFallback` in React's
12320
+ * `chart-surface-treemap.tsx`.
12321
+ */
12322
+ function buildSurfaceViewModel(element, chartData, categoryLabels) {
12323
+ const catCount = Math.max(categoryLabels.length, 1);
12324
+ const seriesCount = chartData.series.length;
12325
+ if (seriesCount >= 2 && catCount >= 2) {
12326
+ return buildIsometricSurfaceViewModel(element, chartData, categoryLabels);
12327
+ }
12328
+ return buildFlatSurfaceViewModel(element, chartData, categoryLabels);
12329
+ }
12330
+ // ─────────────────────────────────────────────────────────────────────────────
12331
+ // Public: buildTreemapViewModel
12332
+ // ─────────────────────────────────────────────────────────────────────────────
12333
+ /**
12334
+ * Build the view-model for a treemap chart.
12335
+ *
12336
+ * Uses a slice-and-dice layout (alternate horizontal/vertical splits) with
12337
+ * items sorted largest-first. Inline labels are added when the cell is wide
12338
+ * enough. Mirrors `renderTreemapChart` in React's `chart-surface-treemap.tsx`.
12339
+ */
12340
+ function buildTreemapViewModel(element, chartData, categoryLabels) {
12341
+ const layout = computePlotLayout(element.width, element.height, chartData, false);
12342
+ const allValues = chartData.series.flatMap((s) => s.values);
12343
+ const totalAbs = allValues.reduce((sum, v) => sum + Math.abs(v), 0) || 1;
12344
+ const primitives = [];
12345
+ let curX = layout.plotLeft;
12346
+ let curY = layout.plotTop;
12347
+ let remainW = layout.plotWidth;
12348
+ let remainH = layout.plotHeight;
12349
+ let remainTotal = totalAbs;
12350
+ // Sort items largest-first then lay out in slice-and-dice order.
12351
+ const items = allValues
12352
+ .map((v, i) => ({ value: Math.abs(v), index: i }))
12353
+ .sort((a, b) => b.value - a.value);
12354
+ for (const item of items) {
12355
+ const fraction = remainTotal > 0 ? item.value / remainTotal : 0;
12356
+ const useWidth = remainW >= remainH;
12357
+ const w = useWidth ? remainW * fraction : remainW;
12358
+ const h = useWidth ? remainH : remainH * fraction;
12359
+ const rw = Math.max(w - 1, 1);
12360
+ const rh = Math.max(h - 1, 1);
12361
+ primitives.push({
12362
+ kind: 'rect',
12363
+ x: curX,
12364
+ y: curY,
12365
+ w: rw,
12366
+ h: rh,
12367
+ fill: paletteColor(item.index, chartData.colorPalette),
12368
+ rx: 2,
12369
+ opacity: 0.85,
12370
+ });
12371
+ const label = categoryLabels[item.index] ?? String(item.index + 1);
12372
+ if (rw > 30 && rh > 14) {
12373
+ primitives.push({
12374
+ kind: 'text',
12375
+ x: curX + rw / 2,
12376
+ y: curY + rh / 2,
12377
+ text: label,
12378
+ fontSize: Math.min(10, rh * 0.3),
12379
+ fill: '#ffffff',
12380
+ textAnchor: 'middle',
12381
+ fontWeight: 'bold',
12382
+ dominantBaseline: 'central',
12383
+ });
12384
+ }
12385
+ if (useWidth) {
12386
+ curX += w;
12387
+ remainW -= w;
12388
+ }
12389
+ else {
12390
+ curY += h;
12391
+ remainH -= h;
12392
+ }
12393
+ remainTotal -= item.value;
12394
+ }
12395
+ // Legend: one entry per series (matching React: no per-item legend).
12396
+ const legendPos = chartData.style?.legendPosition ?? 'b';
12397
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
12398
+ // Build per-category legend entries mirroring the React treemap colour
12399
+ // assignments: one swatch per category/value index.
12400
+ const catLegend = categoryLabels.map((cat, i) => ({
12401
+ color: paletteColor(i, chartData.colorPalette),
12402
+ label: cat,
12403
+ }));
12404
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
12405
+ return {
12406
+ svgWidth: layout.svgWidth,
12407
+ svgHeight: layout.svgHeight,
12408
+ title,
12409
+ titleX: layout.svgWidth / 2,
12410
+ titleY: 14,
12411
+ ...emptyChrome(),
12412
+ primitives,
12413
+ // Prefer per-category legend over per-series legend for treemap.
12414
+ legend: chartData.style?.hasLegend ? (catLegend.length > 0 ? catLegend : legend) : [],
12415
+ legendX,
12416
+ legendY,
12417
+ legendAnchor,
12418
+ };
12419
+ }
12420
+
12421
+ /**
12422
+ * View-model builders for waterfall and regionMap chart kinds.
12423
+ *
12424
+ * Ported from:
12425
+ * packages/react/src/viewer/utils/chart-waterfall-combo.tsx (waterfall only)
12426
+ * packages/react/src/viewer/utils/chart-map.tsx (regionMap)
12427
+ *
12428
+ * Produces a `ChartViewModel` (SVG primitives only, zero Angular dependencies)
12429
+ * that the Angular ChartRendererComponent template iterates over.
12430
+ *
12431
+ * Waterfall – running-total bars with positive/negative/total colouring and
12432
+ * dashed connector lines between bars.
12433
+ * RegionMap – choropleth SVG with simplified world region outlines coloured by
12434
+ * the first data series; unmatched regions fall back to a table.
12435
+ *
12436
+ * @module chart-waterfall-map
12437
+ */
12438
+ // ─────────────────────────────────────────────────────────────────────────────
12439
+ // Waterfall colours (mirrors React renderWaterfallChart)
12440
+ // ─────────────────────────────────────────────────────────────────────────────
12441
+ const WF_COLOR_POSITIVE = '#22c55e';
12442
+ const WF_COLOR_NEGATIVE = '#ef4444';
12443
+ const WF_COLOR_TOTAL = '#6366f1';
12444
+ const WF_CONNECTOR_COLOR = '#94a3b8';
12445
+ // ─────────────────────────────────────────────────────────────────────────────
12446
+ // Public: buildWaterfallViewModel
12447
+ // ─────────────────────────────────────────────────────────────────────────────
12448
+ /**
12449
+ * Build the view-model for a waterfall chart.
12450
+ *
12451
+ * Each bar starts from the running total of all previous values; the last bar
12452
+ * shows the grand total (reset to 0 base). Positive values get a green fill,
12453
+ * negative values get a red fill, and the final total bar uses indigo.
12454
+ * Dashed connector lines join adjacent bar tops/bottoms.
12455
+ *
12456
+ * Mirrors `renderWaterfallChart` in React's `chart-waterfall-combo.tsx`.
12457
+ */
12458
+ function buildWaterfallViewModel(element, chartData, categoryLabels) {
12459
+ const layout = computePlotLayout(element.width, element.height, chartData, true);
12460
+ const values = chartData.series[0]?.values ?? [];
12461
+ const range = computeValueRange(chartData.series);
12462
+ const catCount = Math.max(categoryLabels.length, values.length, 1);
12463
+ const barWidth = (layout.plotWidth / catCount) * 0.6;
12464
+ const gap = (layout.plotWidth / catCount) * 0.2;
12465
+ const primitives = [];
12466
+ const dataLabels = [];
12467
+ let runningTotal = 0;
12468
+ for (let i = 0; i < values.length; i++) {
12469
+ const val = values[i] ?? 0;
12470
+ const isLast = i === values.length - 1;
12471
+ const startVal = isLast ? 0 : runningTotal;
12472
+ const endVal = runningTotal + val;
12473
+ const barStartY = valueToY(startVal, range, layout.plotTop, layout.plotBottom);
12474
+ const barEndY = valueToY(endVal, range, layout.plotTop, layout.plotBottom);
12475
+ const x = layout.plotLeft + (layout.plotWidth / catCount) * i + gap;
12476
+ const y = Math.min(barStartY, barEndY);
12477
+ const h = Math.max(Math.abs(barEndY - barStartY), 1);
12478
+ const barColor = isLast ? WF_COLOR_TOTAL : val >= 0 ? WF_COLOR_POSITIVE : WF_COLOR_NEGATIVE;
12479
+ primitives.push({
12480
+ kind: 'rect',
12481
+ x,
12482
+ y,
12483
+ w: barWidth,
12484
+ h,
12485
+ fill: barColor,
12486
+ rx: 1,
12487
+ });
12488
+ if (chartData.style?.hasDataLabels) {
12489
+ dataLabels.push({
12490
+ kind: 'text',
12491
+ x: x + barWidth / 2,
12492
+ y: y - 4,
12493
+ text: formatAxisValue(isLast ? endVal : val),
12494
+ fontSize: 7,
12495
+ fill: '#334155',
12496
+ textAnchor: 'middle',
12497
+ });
12498
+ }
12499
+ // Connector line to the next bar (not drawn after the last bar).
12500
+ if (!isLast && i < values.length - 1) {
12501
+ const nextX = layout.plotLeft + (layout.plotWidth / catCount) * (i + 1) + gap;
12502
+ primitives.push({
12503
+ kind: 'line',
12504
+ x1: x + barWidth,
12505
+ y1: barEndY,
12506
+ x2: nextX,
12507
+ y2: barEndY,
12508
+ stroke: WF_CONNECTOR_COLOR,
12509
+ strokeWidth: 0.8,
12510
+ dashArray: '3 2',
12511
+ });
12512
+ }
12513
+ if (!isLast) {
12514
+ runningTotal += val;
12515
+ }
12516
+ }
12517
+ const { gridlines, axisLabels } = buildGridlinesAndLabels(range, layout);
12518
+ const zeroLine = buildZeroLine(range, layout);
12519
+ const catLabels = buildCategoryLabels(categoryLabels, layout, 'bar');
12520
+ const legendPos = chartData.style?.legendPosition ?? 'b';
12521
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
12522
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
12523
+ return {
12524
+ svgWidth: layout.svgWidth,
12525
+ svgHeight: layout.svgHeight,
12526
+ title,
12527
+ titleX: layout.svgWidth / 2,
12528
+ titleY: 12,
12529
+ gridlines,
12530
+ axisLabels,
12531
+ zeroLine,
12532
+ categoryLabels: catLabels,
12533
+ primitives,
12534
+ dataLabels,
12535
+ legend: chartData.style?.hasLegend ? legend : [],
12536
+ legendX,
12537
+ legendY,
12538
+ legendAnchor,
12539
+ };
12540
+ }
12541
+ // ─────────────────────────────────────────────────────────────────────────────
12542
+ // RegionMap: region alias lookup
12543
+ // ─────────────────────────────────────────────────────────────────────────────
12544
+ /**
12545
+ * Mapping from common category label strings (country names, ISO codes) to
12546
+ * internal region keys. Case-insensitive lookup.
12547
+ * Mirrors `REGION_ALIAS_MAP` from React's `chart-map.tsx`.
12548
+ */
12549
+ const REGION_ALIAS_MAP = {
12550
+ us: 'US',
12551
+ usa: 'US',
12552
+ 'united states': 'US',
12553
+ 'united states of america': 'US',
12554
+ ca: 'CA',
12555
+ can: 'CA',
12556
+ canada: 'CA',
12557
+ br: 'BR',
12558
+ bra: 'BR',
12559
+ brazil: 'BR',
12560
+ gb: 'GB',
12561
+ gbr: 'GB',
12562
+ uk: 'GB',
12563
+ 'united kingdom': 'GB',
12564
+ fr: 'FR',
12565
+ fra: 'FR',
12566
+ france: 'FR',
12567
+ de: 'DE',
12568
+ deu: 'DE',
12569
+ germany: 'DE',
12570
+ it: 'IT',
12571
+ ita: 'IT',
12572
+ italy: 'IT',
12573
+ es: 'ES',
12574
+ esp: 'ES',
12575
+ spain: 'ES',
12576
+ ru: 'RU',
12577
+ rus: 'RU',
12578
+ russia: 'RU',
12579
+ cn: 'CN',
12580
+ chn: 'CN',
12581
+ china: 'CN',
12582
+ in: 'IN',
12583
+ ind: 'IN',
12584
+ india: 'IN',
12585
+ jp: 'JP',
12586
+ jpn: 'JP',
12587
+ japan: 'JP',
12588
+ kr: 'KR',
12589
+ kor: 'KR',
12590
+ 'south korea': 'KR',
12591
+ korea: 'KR',
12592
+ au: 'AU',
12593
+ aus: 'AU',
12594
+ australia: 'AU',
12595
+ mx: 'MX',
12596
+ mex: 'MX',
12597
+ mexico: 'MX',
12598
+ id: 'ID',
12599
+ idn: 'ID',
12600
+ indonesia: 'ID',
12601
+ tr: 'TR',
12602
+ tur: 'TR',
12603
+ turkey: 'TR',
12604
+ sa: 'SA',
12605
+ sau: 'SA',
12606
+ 'saudi arabia': 'SA',
12607
+ za: 'ZA',
12608
+ zaf: 'ZA',
12609
+ 'south africa': 'ZA',
12610
+ ar: 'AR',
12611
+ arg: 'AR',
12612
+ argentina: 'AR',
12613
+ ng: 'NG',
12614
+ nga: 'NG',
12615
+ nigeria: 'NG',
12616
+ eg: 'EG',
12617
+ egy: 'EG',
12618
+ egypt: 'EG',
12619
+ };
12620
+ /** Resolve a category label to a region key (case-insensitive). */
12621
+ function resolveRegionCode(label) {
12622
+ const normalized = label.trim().toLowerCase();
12623
+ return REGION_ALIAS_MAP[normalized];
12624
+ }
12625
+ // ─────────────────────────────────────────────────────────────────────────────
12626
+ // RegionMap: colour scale helpers
12627
+ // ─────────────────────────────────────────────────────────────────────────────
12628
+ /** Interpolate between two hex colours by ratio t in [0..1]. */
12629
+ function lerpColor(a, b, t) {
12630
+ const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
12631
+ const ha = a.replace('#', '');
12632
+ const hb = b.replace('#', '');
12633
+ const r1 = parseInt(ha.substring(0, 2), 16);
12634
+ const g1 = parseInt(ha.substring(2, 4), 16);
12635
+ const b1 = parseInt(ha.substring(4, 6), 16);
12636
+ const r2 = parseInt(hb.substring(0, 2), 16);
12637
+ const g2 = parseInt(hb.substring(2, 4), 16);
12638
+ const b2 = parseInt(hb.substring(4, 6), 16);
12639
+ const r = clamp(r1 + (r2 - r1) * t);
12640
+ const g = clamp(g1 + (g2 - g1) * t);
12641
+ const bl = clamp(b1 + (b2 - b1) * t);
12642
+ const toHex = (n) => n.toString(16).padStart(2, '0');
12643
+ return `#${toHex(r)}${toHex(g)}${toHex(bl)}`;
12644
+ }
12645
+ /**
12646
+ * 3-stop sequential colour scale: light (#dbeafe) → mid (#3b82f6) → dark (#1e3a5f).
12647
+ * Mirrors `sequentialColorScale` in React's `chart-map.tsx`.
12648
+ */
12649
+ function sequentialColorScale(t) {
12650
+ const clamped = Math.max(0, Math.min(1, t));
12651
+ if (clamped <= 0.5) {
12652
+ return lerpColor('#dbeafe', '#3b82f6', clamped * 2);
12653
+ }
12654
+ return lerpColor('#3b82f6', '#1e3a5f', (clamped - 0.5) * 2);
12655
+ }
12656
+ /** Normalise a value to [0..1] within a min/max range. */
12657
+ function normalizeValue(value, min, max) {
12658
+ if (max === min) {
12659
+ return 0.5;
12660
+ }
12661
+ return (value - min) / (max - min);
12662
+ }
12663
+ /** Simplified world region outlines (mirrors `WORLD_REGIONS` in React's chart-map.tsx). */
12664
+ const WORLD_REGIONS = [
12665
+ {
12666
+ code: 'US',
12667
+ name: 'United States',
12668
+ path: 'M130,160 L250,155 265,170 270,190 260,210 230,215 200,220 170,215 145,205 130,195Z M280,175 L295,165 310,170 310,185 295,195 280,190Z',
12669
+ labelXY: [200, 190],
12670
+ },
12671
+ {
12672
+ code: 'CA',
12673
+ name: 'Canada',
12674
+ path: 'M120,90 L280,85 290,100 295,130 280,150 250,155 200,155 160,155 130,155 115,140 110,115Z',
12675
+ labelXY: [200, 125],
12676
+ },
12677
+ {
12678
+ code: 'MX',
12679
+ name: 'Mexico',
12680
+ path: 'M145,215 L200,220 210,235 200,255 185,265 165,260 150,245 140,230Z',
12681
+ labelXY: [175, 240],
12682
+ },
12683
+ {
12684
+ code: 'BR',
12685
+ name: 'Brazil',
12686
+ path: 'M270,300 L310,280 335,290 340,320 330,355 310,370 285,365 265,345 260,320Z',
12687
+ labelXY: [300, 330],
12688
+ },
12689
+ {
12690
+ code: 'AR',
12691
+ name: 'Argentina',
12692
+ path: 'M260,370 L280,365 290,380 285,410 275,435 260,445 250,425 248,395Z',
12693
+ labelXY: [268, 410],
12694
+ },
12695
+ {
12696
+ code: 'GB',
12697
+ name: 'United Kingdom',
12698
+ path: 'M440,120 L448,110 455,115 455,135 448,142 440,138Z',
12699
+ labelXY: [448, 128],
12700
+ },
12701
+ {
12702
+ code: 'FR',
12703
+ name: 'France',
12704
+ path: 'M450,145 L470,140 480,150 478,168 465,175 450,170 445,158Z',
12705
+ labelXY: [463, 158],
12706
+ },
12707
+ {
12708
+ code: 'DE',
12709
+ name: 'Germany',
12710
+ path: 'M478,125 L498,120 505,130 502,148 490,152 478,148 475,138Z',
12711
+ labelXY: [490, 138],
12712
+ },
12713
+ {
12714
+ code: 'IT',
12715
+ name: 'Italy',
12716
+ path: 'M490,155 L498,152 505,162 500,180 492,190 488,178 486,165Z',
12717
+ labelXY: [495, 172],
12718
+ },
12719
+ {
12720
+ code: 'ES',
12721
+ name: 'Spain',
12722
+ path: 'M432,168 L460,165 465,175 460,188 442,192 430,185 428,175Z',
12723
+ labelXY: [448, 180],
12724
+ },
12725
+ {
12726
+ code: 'RU',
12727
+ name: 'Russia',
12728
+ path: 'M510,60 L780,50 830,70 840,100 820,120 750,115 700,105 650,100 580,105 530,110 510,100 505,80Z',
12729
+ labelXY: [670, 85],
12730
+ },
12731
+ {
12732
+ code: 'TR',
12733
+ name: 'Turkey',
12734
+ path: 'M530,165 L570,160 585,170 580,182 555,185 530,180Z',
12735
+ labelXY: [558, 175],
12736
+ },
12737
+ {
12738
+ code: 'EG',
12739
+ name: 'Egypt',
12740
+ path: 'M530,200 L555,195 565,205 560,225 545,230 530,222Z',
12741
+ labelXY: [548, 215],
12742
+ },
12743
+ {
12744
+ code: 'NG',
12745
+ name: 'Nigeria',
12746
+ path: 'M475,275 L500,270 510,280 505,298 490,302 475,295Z',
12747
+ labelXY: [492, 288],
12748
+ },
12749
+ {
12750
+ code: 'ZA',
12751
+ name: 'South Africa',
12752
+ path: 'M520,380 L545,370 560,380 555,400 540,410 520,405 515,392Z',
12753
+ labelXY: [538, 392],
12754
+ },
12755
+ {
12756
+ code: 'SA',
12757
+ name: 'Saudi Arabia',
12758
+ path: 'M565,220 L600,210 615,225 610,250 590,258 570,250 560,238Z',
12759
+ labelXY: [590, 238],
12760
+ },
12761
+ {
12762
+ code: 'IN',
12763
+ name: 'India',
12764
+ path: 'M640,210 L665,195 685,210 688,240 678,268 660,278 645,265 635,240Z',
12765
+ labelXY: [662, 240],
12766
+ },
12767
+ {
12768
+ code: 'CN',
12769
+ name: 'China',
12770
+ path: 'M700,120 L775,115 800,130 805,160 790,180 760,185 730,180 710,168 695,150 690,135Z',
12771
+ labelXY: [750, 155],
12772
+ },
12773
+ {
12774
+ code: 'JP',
12775
+ name: 'Japan',
12776
+ path: 'M835,145 L845,135 852,140 850,158 842,165 835,160Z',
12777
+ labelXY: [843, 152],
12778
+ },
12779
+ {
12780
+ code: 'KR',
12781
+ name: 'South Korea',
12782
+ path: 'M815,158 L825,152 830,160 827,170 818,172 813,165Z',
12783
+ labelXY: [822, 163],
12784
+ },
12785
+ {
12786
+ code: 'ID',
12787
+ name: 'Indonesia',
12788
+ path: 'M740,295 L780,288 810,292 830,298 825,310 790,312 755,308 740,305Z',
12789
+ labelXY: [785, 302],
12790
+ },
12791
+ {
12792
+ code: 'AU',
12793
+ name: 'Australia',
12794
+ path: 'M790,350 L850,340 880,355 885,385 870,405 840,410 810,400 790,380Z',
12795
+ labelXY: [838, 378],
12796
+ },
12797
+ ];
12798
+ // ─────────────────────────────────────────────────────────────────────────────
12799
+ // Public: buildRegionMapViewModel
12800
+ // ─────────────────────────────────────────────────────────────────────────────
12801
+ /**
12802
+ * Build the view-model for a regionMap (choropleth) chart.
12803
+ *
12804
+ * Matches category labels against known world regions, colours them by the
12805
+ * first series' values using a sequential blue colour scale, and renders a
12806
+ * simple colour-legend bar below the map. Unmatched regions are collected
12807
+ * into a small fallback table rendered as SVG text rows.
12808
+ *
12809
+ * Mirrors `renderMapChart` in React's `chart-map.tsx`.
12810
+ */
12811
+ function buildRegionMapViewModel(element, chartData, categoryLabels) {
12812
+ const svgWidth = Math.max(element.width, 320);
12813
+ const svgHeight = Math.max(element.height, 200);
12814
+ const categories = categoryLabels.length > 0 ? categoryLabels : chartData.categories;
12815
+ const values = chartData.series.length > 0 ? (chartData.series[0]?.values ?? []) : [];
12816
+ const finiteVals = values.filter((v) => Number.isFinite(v));
12817
+ const minVal = finiteVals.length > 0 ? Math.min(...finiteVals) : 0;
12818
+ const maxVal = finiteVals.length > 0 ? Math.max(...finiteVals) : 1;
12819
+ // Build region → value lookup.
12820
+ const regionValueMap = new Map();
12821
+ const unmatchedRows = [];
12822
+ for (let i = 0; i < categories.length; i++) {
12823
+ const cat = categories[i] ?? '';
12824
+ const value = values[i] ?? 0;
12825
+ const code = resolveRegionCode(cat);
12826
+ if (code !== undefined) {
12827
+ regionValueMap.set(code, { value, label: cat });
12828
+ }
12829
+ else {
12830
+ unmatchedRows.push({ label: cat, value });
12831
+ }
12832
+ }
12833
+ // Layout measurements.
12834
+ const legendHeight = 30;
12835
+ const fallbackRowH = 14;
12836
+ const maxFallbackRows = Math.min(unmatchedRows.length, 5);
12837
+ const fallbackTableH = unmatchedRows.length > 0 ? (maxFallbackRows + 1) * fallbackRowH + 8 : 0;
12838
+ const titleH = chartData.title ? 22 : 0;
12839
+ const mapAreaH = Math.max(svgHeight - titleH - legendHeight - fallbackTableH - 8, 80);
12840
+ // Scale the 1000 x 500 region coordinate space into the available area.
12841
+ const mapScale = Math.min((svgWidth - 20) / 1000, mapAreaH / 500);
12842
+ const mapOffsetX = (svgWidth - 1000 * mapScale) / 2;
12843
+ const mapOffsetY = titleH + 4;
12844
+ const primitives = [];
12845
+ // Background.
12846
+ primitives.push({
12847
+ kind: 'rect',
12848
+ x: 0,
12849
+ y: 0,
12850
+ w: svgWidth,
12851
+ h: svgHeight,
12852
+ fill: '#f8fafc',
12853
+ rx: 4,
12854
+ });
12855
+ // Title text.
12856
+ const titlePrimitive = chartData.title
12857
+ ? {
12858
+ kind: 'text',
12859
+ x: svgWidth / 2,
12860
+ y: 16,
12861
+ text: chartData.title,
12862
+ fontSize: 12,
12863
+ fill: '#334155',
12864
+ textAnchor: 'middle',
12865
+ fontWeight: 'bold',
12866
+ dominantBaseline: 'auto',
12867
+ }
12868
+ : undefined;
12869
+ // Region shape paths.
12870
+ for (const region of WORLD_REGIONS) {
12871
+ const entry = regionValueMap.get(region.code);
12872
+ let fill = '#e2e8f0';
12873
+ if (entry !== undefined) {
12874
+ const t = normalizeValue(entry.value, minVal, maxVal);
12875
+ fill = sequentialColorScale(t);
12876
+ }
12877
+ // Embed the transform in the path's d attribute via a manual coordinate
12878
+ // scale+translate since SvgPath has no transform field. We replicate
12879
+ // React's `transform="translate(mapOffsetX,mapOffsetY) scale(mapScale)"`
12880
+ // by pre-scaling every coordinate pair in the path string.
12881
+ const scaledPath = scalePathD(region.path, mapScale, mapOffsetX, mapOffsetY);
12882
+ primitives.push({
12883
+ kind: 'path',
12884
+ d: scaledPath,
12885
+ fill,
12886
+ stroke: '#94a3b8',
12887
+ strokeWidth: Math.max(0.5 / mapScale, 0.3),
12888
+ });
12889
+ // Inline data label for matched regions.
12890
+ if (entry !== undefined) {
12891
+ const lx = region.labelXY[0] * mapScale + mapOffsetX;
12892
+ const ly = region.labelXY[1] * mapScale + mapOffsetY + 4;
12893
+ primitives.push({
12894
+ kind: 'text',
12895
+ x: lx,
12896
+ y: ly,
12897
+ text: formatAxisValue(entry.value),
12898
+ fontSize: Math.max(6, 7 * mapScale),
12899
+ fill: '#1e293b',
12900
+ textAnchor: 'middle',
12901
+ fontWeight: 'bold',
12902
+ dominantBaseline: 'central',
12903
+ });
12904
+ }
12905
+ }
12906
+ // Colour legend bar (rendered as gradient approximation: three rect stops).
12907
+ const legendY = mapOffsetY + mapAreaH + 4;
12908
+ const barW = Math.min(svgWidth * 0.4, 160);
12909
+ const barX = (svgWidth - barW) / 2;
12910
+ // Approximate the gradient using three colour stops as adjacent rects.
12911
+ const gradStops = [
12912
+ { offset: 0, color: '#dbeafe' },
12913
+ { offset: 0.5, color: '#3b82f6' },
12914
+ { offset: 1, color: '#1e3a5f' },
12915
+ ];
12916
+ const stopCount = gradStops.length - 1;
12917
+ for (let si = 0; si < stopCount; si++) {
12918
+ const stopA = gradStops[si];
12919
+ const stopB = gradStops[si + 1];
12920
+ if (stopA === undefined || stopB === undefined) {
12921
+ continue;
12922
+ }
12923
+ // Use the midpoint colour of each segment.
12924
+ const midColor = lerpColor(stopA.color, stopB.color, 0.5);
12925
+ primitives.push({
12926
+ kind: 'rect',
12927
+ x: barX + stopA.offset * barW,
12928
+ y: legendY,
12929
+ w: (stopB.offset - stopA.offset) * barW,
12930
+ h: 8,
12931
+ fill: midColor,
12932
+ rx: si === 0 ? 4 : 0,
12933
+ });
12934
+ }
12935
+ // Legend min/max labels.
12936
+ primitives.push({
12937
+ kind: 'text',
12938
+ x: barX,
12939
+ y: legendY + 18,
12940
+ text: formatAxisValue(minVal),
12941
+ fontSize: 7,
12942
+ fill: '#64748b',
12943
+ textAnchor: 'middle',
12944
+ }, {
12945
+ kind: 'text',
12946
+ x: barX + barW,
12947
+ y: legendY + 18,
12948
+ text: formatAxisValue(maxVal),
12949
+ fontSize: 7,
12950
+ fill: '#64748b',
12951
+ textAnchor: 'middle',
12952
+ });
12953
+ // Fallback table for unmatched regions.
12954
+ if (unmatchedRows.length > 0) {
12955
+ const tableY = legendY + 26;
12956
+ const fontSize = Math.min(8, fallbackRowH * 0.7);
12957
+ const colW = Math.min((svgWidth - 20) / 2, 120);
12958
+ const tableX = (svgWidth - colW * 2) / 2;
12959
+ primitives.push({
12960
+ kind: 'text',
12961
+ x: svgWidth / 2,
12962
+ y: tableY,
12963
+ text: 'Additional regions (not shown on map)',
12964
+ fontSize: 7,
12965
+ fill: '#94a3b8',
12966
+ textAnchor: 'middle',
12967
+ });
12968
+ for (let i = 0; i < maxFallbackRows; i++) {
12969
+ const row = unmatchedRows[i];
12970
+ if (row === undefined) {
12971
+ continue;
12972
+ }
12973
+ const ry = tableY + fallbackRowH * (i + 1);
12974
+ if (ry + fallbackRowH > svgHeight) {
12975
+ break;
12976
+ }
12977
+ if (i % 2 === 0) {
12978
+ primitives.push({
12979
+ kind: 'rect',
12980
+ x: tableX,
12981
+ y: ry - fallbackRowH + 4,
12982
+ w: colW * 2,
12983
+ h: fallbackRowH,
12984
+ fill: '#f1f5f9',
12985
+ rx: 2,
12986
+ });
12987
+ }
12988
+ primitives.push({
12989
+ kind: 'text',
12990
+ x: tableX + 4,
12991
+ y: ry,
12992
+ text: row.label,
12993
+ fontSize,
12994
+ fill: '#334155',
12995
+ textAnchor: 'start',
12996
+ }, {
12997
+ kind: 'text',
12998
+ x: tableX + colW + 4,
12999
+ y: ry,
13000
+ text: formatAxisValue(row.value),
13001
+ fontSize,
13002
+ fill: '#475569',
13003
+ textAnchor: 'start',
13004
+ });
13005
+ }
13006
+ if (unmatchedRows.length > 5) {
13007
+ const moreY = tableY + fallbackRowH * 6;
13008
+ primitives.push({
13009
+ kind: 'text',
13010
+ x: svgWidth / 2,
13011
+ y: moreY,
13012
+ text: `+${unmatchedRows.length - 5} more regions`,
13013
+ fontSize: 6,
13014
+ fill: '#94a3b8',
13015
+ textAnchor: 'middle',
13016
+ });
13017
+ }
13018
+ }
13019
+ const dataLabels = titlePrimitive !== undefined ? [titlePrimitive] : [];
13020
+ return {
13021
+ svgWidth,
13022
+ svgHeight,
13023
+ title: undefined, // Rendered inline as a dataLabel text primitive above.
13024
+ titleX: svgWidth / 2,
13025
+ titleY: 14,
13026
+ gridlines: [],
13027
+ axisLabels: [],
13028
+ zeroLine: undefined,
13029
+ categoryLabels: [],
13030
+ primitives,
13031
+ dataLabels,
13032
+ legend: [],
13033
+ legendX: svgWidth / 2,
13034
+ legendY: svgHeight - 8,
13035
+ legendAnchor: 'middle',
13036
+ };
13037
+ }
13038
+ // ─────────────────────────────────────────────────────────────────────────────
13039
+ // Internal: scale a simplified SVG path d string
13040
+ // ─────────────────────────────────────────────────────────────────────────────
13041
+ /**
13042
+ * Pre-scale and translate each coordinate pair in a simple SVG path `d`
13043
+ * attribute (M/L/Z commands only, space/comma delimited).
13044
+ *
13045
+ * This avoids needing a `transform` attribute on `SvgPath` which doesn't exist
13046
+ * in the existing primitive schema.
13047
+ */
13048
+ function scalePathD(d, scale, dx, dy) {
13049
+ // Tokenise: split on whitespace, commas, and command letters while keeping
13050
+ // command letters in the output.
13051
+ const tokens = d.trim().split(/[\s,]+/u);
13052
+ const out = [];
13053
+ let i = 0;
13054
+ while (i < tokens.length) {
13055
+ const tok = tokens[i];
13056
+ if (tok === undefined) {
13057
+ i++;
13058
+ continue;
13059
+ }
13060
+ // Command letter (M, L, Z, etc.)
13061
+ if (/^[A-Za-z]$/u.test(tok)) {
13062
+ out.push(tok);
13063
+ i++;
13064
+ continue;
13065
+ }
13066
+ // Coordinate pair: tok = x value, tokens[i+1] = y value.
13067
+ const xRaw = parseFloat(tok);
13068
+ const yRaw = parseFloat(tokens[i + 1] ?? '0');
13069
+ if (!Number.isNaN(xRaw) && !Number.isNaN(yRaw)) {
13070
+ out.push(`${(xRaw * scale + dx).toFixed(2)},${(yRaw * scale + dy).toFixed(2)}`);
13071
+ i += 2;
13072
+ }
13073
+ else {
13074
+ out.push(tok);
13075
+ i++;
13076
+ }
13077
+ }
13078
+ return out.join(' ');
13079
+ }
13080
+
11836
13081
  /**
11837
13082
  * Pure, framework-agnostic helpers for chart rendering.
11838
13083
  *
@@ -11854,9 +13099,11 @@ function applyResize(start, handle, dx, dy, min = MIN_RESIZE) {
11854
13099
  * area / area3D -> polygon fill + polyline
11855
13100
  * pie / doughnut / pie3D / ofPie -> arc paths
11856
13101
  * scatter -> circle dots
13102
+ * bubble -> circle dots sized by a 3rd series
13103
+ * radar / radar3D -> polar polygons + spokes
11857
13104
  *
11858
13105
  * Deferred (fallback box rendered instead):
11859
- * bubble, radar, stock, waterfall, combo, surface, treemap, sunburst,
13106
+ * stock, waterfall, combo, surface, treemap, sunburst,
11860
13107
  * funnel, boxWhisker, histogram, regionMap, bar3D (complex 3-D shading),
11861
13108
  * error bars, trendlines, secondary axes, data tables.
11862
13109
  *
@@ -12198,6 +13445,47 @@ function computeScatterDots(values, maxXIndex, layout, range) {
12198
13445
  cy: valueToY(val, range, layout.plotTop, layout.plotBottom),
12199
13446
  }));
12200
13447
  }
13448
+ // ─────────────────────────────────────────────────────────────────────────────
13449
+ // Bubble
13450
+ // ─────────────────────────────────────────────────────────────────────────────
13451
+ /**
13452
+ * Radius of a bubble given its size value, the max size in the chart, and a
13453
+ * median radius derived from the plot area. Mirrors `renderBubbleChart` in
13454
+ * React's chart-scatter-bubble.tsx: when no size value is present the bubble
13455
+ * uses the median radius; otherwise it scales from 0.5x to 2x the median.
13456
+ */
13457
+ function computeBubbleRadius(sizeVal, maxBubble, medianRadius) {
13458
+ if (sizeVal === undefined) {
13459
+ return medianRadius;
13460
+ }
13461
+ const denom = maxBubble > 0 ? maxBubble : 1;
13462
+ return medianRadius * 0.5 + (Math.abs(sizeVal) / denom) * medianRadius * 1.5;
13463
+ }
13464
+ // ─────────────────────────────────────────────────────────────────────────────
13465
+ // Radar
13466
+ // ─────────────────────────────────────────────────────────────────────────────
13467
+ /** Angle (radians) of the i-th radar spoke; 0 points up (-90°), clockwise. */
13468
+ function radarAngle(index, catCount) {
13469
+ const n = Math.max(catCount, 1);
13470
+ return (Math.PI * 2 * index) / n - Math.PI / 2;
13471
+ }
13472
+ /** Project a series' values onto radar (polar) coordinates around (cx, cy). */
13473
+ function computeRadarPoints(values, maxVal, radius, cx, cy, catCount) {
13474
+ const denom = maxVal > 0 ? maxVal : 1;
13475
+ return values.slice(0, Math.max(catCount, 1)).map((val, i) => {
13476
+ const angle = radarAngle(i, catCount);
13477
+ const r = (Math.abs(val) / denom) * radius;
13478
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
13479
+ });
13480
+ }
13481
+ /** Points string for a radar gridline ring at radius `rr`. */
13482
+ function radarRingPoints(cx, cy, rr, catCount) {
13483
+ const n = Math.max(catCount, 1);
13484
+ return Array.from({ length: n }, (_, i) => {
13485
+ const angle = radarAngle(i, n);
13486
+ return `${(cx + rr * Math.cos(angle)).toFixed(2)},${(cy + rr * Math.sin(angle)).toFixed(2)}`;
13487
+ }).join(' ');
13488
+ }
12201
13489
  function resolveChartKind(chartType) {
12202
13490
  switch (chartType) {
12203
13491
  case 'bar':
@@ -12217,6 +13505,24 @@ function resolveChartKind(chartType) {
12217
13505
  return 'doughnut';
12218
13506
  case 'scatter':
12219
13507
  return 'scatter';
13508
+ case 'bubble':
13509
+ return 'bubble';
13510
+ case 'radar':
13511
+ case 'radar3D':
13512
+ return 'radar';
13513
+ case 'combo':
13514
+ return 'combo';
13515
+ case 'stock':
13516
+ return 'stock';
13517
+ case 'surface':
13518
+ case 'surface3D':
13519
+ return 'surface';
13520
+ case 'treemap':
13521
+ return 'treemap';
13522
+ case 'waterfall':
13523
+ return 'waterfall';
13524
+ case 'regionMap':
13525
+ return 'regionMap';
12220
13526
  default:
12221
13527
  return 'unsupported';
12222
13528
  }
@@ -12245,6 +13551,27 @@ function buildChartViewModel(element) {
12245
13551
  if (kind === 'pie' || kind === 'doughnut') {
12246
13552
  return buildPieViewModel(element, chartData, categoryLabels, kind === 'doughnut');
12247
13553
  }
13554
+ if (kind === 'radar') {
13555
+ return buildRadarViewModel(element, chartData, categoryLabels);
13556
+ }
13557
+ if (kind === 'combo') {
13558
+ return buildComboViewModel(element, chartData, categoryLabels);
13559
+ }
13560
+ if (kind === 'stock') {
13561
+ return buildStockViewModel(element, chartData, categoryLabels);
13562
+ }
13563
+ if (kind === 'surface') {
13564
+ return buildSurfaceViewModel(element, chartData, categoryLabels);
13565
+ }
13566
+ if (kind === 'treemap') {
13567
+ return buildTreemapViewModel(element, chartData, categoryLabels);
13568
+ }
13569
+ if (kind === 'waterfall') {
13570
+ return buildWaterfallViewModel(element, chartData, categoryLabels);
13571
+ }
13572
+ if (kind === 'regionMap') {
13573
+ return buildRegionMapViewModel(element, chartData, categoryLabels);
13574
+ }
12248
13575
  return buildCartesianViewModel(element, chartData, categoryLabels, kind);
12249
13576
  }
12250
13577
  function buildFallbackViewModel(width, height, label) {
@@ -12361,7 +13688,7 @@ function buildCartesianViewModel(element, chartData, categoryLabels, kind) {
12361
13688
  : computeValueRange(chartData.series);
12362
13689
  const { gridlines, axisLabels } = buildGridlinesAndLabels(range, layout);
12363
13690
  const zeroLine = buildZeroLine(range, layout);
12364
- const catAxisStyle = kind === 'line' || kind === 'area' || kind === 'scatter' ? 'line' : 'bar';
13691
+ const catAxisStyle = kind === 'line' || kind === 'area' || kind === 'scatter' || kind === 'bubble' ? 'line' : 'bar';
12365
13692
  const catLabels = buildCategoryLabels(categoryLabels, layout, catAxisStyle);
12366
13693
  const legendPos = chartData.style?.legendPosition ?? 'b';
12367
13694
  const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
@@ -12536,9 +13863,165 @@ function buildCartesianViewModel(element, chartData, categoryLabels, kind) {
12536
13863
  textAnchor: 'middle',
12537
13864
  });
12538
13865
  });
12539
- }
13866
+ }
13867
+ }
13868
+ }
13869
+ else if (kind === 'bubble') {
13870
+ // X = category index, Y = first/second series value, size = third series.
13871
+ const allIndices = chartData.series.flatMap((s) => s.values.map((_, i) => i));
13872
+ const maxXIndex = Math.max(1, ...allIndices);
13873
+ const sizeSeries = chartData.series.length >= 3 ? chartData.series[2] : undefined;
13874
+ const maxBubble = sizeSeries ? Math.max(1, ...sizeSeries.values.map((v) => Math.abs(v))) : 1;
13875
+ const medianRadius = Math.min(layout.plotWidth, layout.plotHeight) * 0.04;
13876
+ const pointSeries = chartData.series.slice(0, 2);
13877
+ for (let si = 0; si < pointSeries.length; si++) {
13878
+ const series = pointSeries[si];
13879
+ const c = seriesColor(series, si, chartData.colorPalette);
13880
+ const dots = computeScatterDots(series.values, maxXIndex, layout, range);
13881
+ dots.forEach((dot, vi) => {
13882
+ const r = computeBubbleRadius(sizeSeries?.values[vi], maxBubble, medianRadius);
13883
+ primitives.push({
13884
+ kind: 'circle',
13885
+ cx: dot.cx,
13886
+ cy: dot.cy,
13887
+ r,
13888
+ fill: c,
13889
+ opacity: 0.6,
13890
+ });
13891
+ });
13892
+ if (chartData.style?.hasDataLabels) {
13893
+ series.values.forEach((val, vi) => {
13894
+ const dot = dots[vi];
13895
+ if (!dot) {
13896
+ return;
13897
+ }
13898
+ dataLabels.push({
13899
+ kind: 'text',
13900
+ x: dot.cx,
13901
+ y: dot.cy - 10,
13902
+ text: formatAxisValue(val),
13903
+ fontSize: 7,
13904
+ fill: '#334155',
13905
+ textAnchor: 'middle',
13906
+ });
13907
+ });
13908
+ }
13909
+ }
13910
+ }
13911
+ const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
13912
+ return {
13913
+ svgWidth: layout.svgWidth,
13914
+ svgHeight: layout.svgHeight,
13915
+ title,
13916
+ titleX: layout.svgWidth / 2,
13917
+ titleY: 12,
13918
+ gridlines,
13919
+ axisLabels,
13920
+ zeroLine,
13921
+ categoryLabels: catLabels,
13922
+ primitives,
13923
+ dataLabels,
13924
+ legend: chartData.style?.hasLegend ? legend : [],
13925
+ legendX,
13926
+ legendY,
13927
+ legendAnchor,
13928
+ };
13929
+ }
13930
+ const RADAR_RINGS = 4;
13931
+ const RADAR_RING_COLOR = '#cbd5e1';
13932
+ const RADAR_SPOKE_COLOR = '#94a3b8';
13933
+ const RADAR_LABEL_COLOR = '#64748b';
13934
+ /**
13935
+ * Build the view-model for a radar / spider chart. Polar, so it has no
13936
+ * cartesian gridlines/axes; ring + spoke geometry and the data polygons all
13937
+ * live in `primitives`, perimeter category labels in `categoryLabels`.
13938
+ * Mirrors React's `renderRadarChart` (chart-radar.tsx).
13939
+ */
13940
+ function buildRadarViewModel(element, chartData, categoryLabels) {
13941
+ const layout = computePlotLayout(element.width, element.height, chartData, false);
13942
+ const cx = layout.plotLeft + layout.plotWidth / 2;
13943
+ const cy = layout.plotTop + layout.plotHeight / 2;
13944
+ const radius = Math.max(Math.min(layout.plotWidth, layout.plotHeight) / 2 - 4, 1);
13945
+ const catCount = Math.max(categoryLabels.length, 1);
13946
+ const maxVal = Math.max(1, ...chartData.series.flatMap((s) => s.values.map((v) => Math.abs(v))));
13947
+ const primitives = [];
13948
+ const perimeterLabels = [];
13949
+ // Concentric gridline rings (dashed except the outermost).
13950
+ for (let r = 1; r <= RADAR_RINGS; r++) {
13951
+ const rr = (radius * r) / RADAR_RINGS;
13952
+ primitives.push({
13953
+ kind: 'polygon',
13954
+ points: radarRingPoints(cx, cy, rr, catCount),
13955
+ fill: 'none',
13956
+ stroke: RADAR_RING_COLOR,
13957
+ strokeWidth: 0.5,
13958
+ dashArray: r < RADAR_RINGS ? '3 2' : undefined,
13959
+ });
13960
+ }
13961
+ // Axis spokes + perimeter category labels.
13962
+ for (let i = 0; i < catCount; i++) {
13963
+ const angle = radarAngle(i, catCount);
13964
+ primitives.push({
13965
+ kind: 'line',
13966
+ x1: cx,
13967
+ y1: cy,
13968
+ x2: cx + radius * Math.cos(angle),
13969
+ y2: cy + radius * Math.sin(angle),
13970
+ stroke: RADAR_SPOKE_COLOR,
13971
+ strokeWidth: 0.5,
13972
+ });
13973
+ const labelR = radius + 10;
13974
+ perimeterLabels.push({
13975
+ kind: 'text',
13976
+ x: cx + labelR * Math.cos(angle),
13977
+ y: cy + labelR * Math.sin(angle),
13978
+ text: categoryLabels[i] ?? '',
13979
+ fontSize: 8,
13980
+ fill: RADAR_LABEL_COLOR,
13981
+ textAnchor: 'middle',
13982
+ dominantBaseline: 'central',
13983
+ });
13984
+ }
13985
+ // Per-series data polygons + vertex dots.
13986
+ const dataLabels = [];
13987
+ chartData.series.forEach((series, si) => {
13988
+ const c = seriesColor(series, si, chartData.colorPalette);
13989
+ const pts = computeRadarPoints(series.values, maxVal, radius, cx, cy, catCount);
13990
+ if (pts.length === 0) {
13991
+ return;
13992
+ }
13993
+ const pointsStr = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ');
13994
+ primitives.push({
13995
+ kind: 'polygon',
13996
+ points: pointsStr,
13997
+ fill: c,
13998
+ opacity: 0.2,
13999
+ stroke: c,
14000
+ strokeWidth: 1.5,
14001
+ });
14002
+ for (const p of pts) {
14003
+ primitives.push({ kind: 'circle', cx: p.x, cy: p.y, r: 3, fill: c });
14004
+ }
14005
+ if (chartData.style?.hasDataLabels) {
14006
+ pts.forEach((p, vi) => {
14007
+ const val = series.values[vi];
14008
+ if (val === undefined) {
14009
+ return;
14010
+ }
14011
+ dataLabels.push({
14012
+ kind: 'text',
14013
+ x: p.x,
14014
+ y: p.y - 8,
14015
+ text: formatAxisValue(val),
14016
+ fontSize: 7,
14017
+ fill: '#334155',
14018
+ textAnchor: 'middle',
14019
+ });
14020
+ });
12540
14021
  }
12541
- }
14022
+ });
14023
+ const legendPos = chartData.style?.legendPosition ?? 'b';
14024
+ const { legend, legendX, legendY, legendAnchor } = buildLegend(chartData.series, chartData.colorPalette, layout.svgWidth, legendPos, layout.svgHeight, layout.plotTop);
12542
14025
  const title = chartData.style?.hasTitle && chartData.title ? chartData.title : undefined;
12543
14026
  return {
12544
14027
  svgWidth: layout.svgWidth,
@@ -12546,10 +14029,10 @@ function buildCartesianViewModel(element, chartData, categoryLabels, kind) {
12546
14029
  title,
12547
14030
  titleX: layout.svgWidth / 2,
12548
14031
  titleY: 12,
12549
- gridlines,
12550
- axisLabels,
12551
- zeroLine,
12552
- categoryLabels: catLabels,
14032
+ gridlines: [],
14033
+ axisLabels: [],
14034
+ zeroLine: undefined,
14035
+ categoryLabels: perimeterLabels,
12553
14036
  primitives,
12554
14037
  dataLabels,
12555
14038
  legend: chartData.style?.hasLegend ? legend : [],
@@ -12569,9 +14052,11 @@ function buildCartesianViewModel(element, chartData, categoryLabels, kind) {
12569
14052
  * area / area3D
12570
14053
  * pie / doughnut / pie3D / ofPie
12571
14054
  * scatter
14055
+ * bubble
14056
+ * radar / radar3D
12572
14057
  *
12573
14058
  * Deferred (labelled fallback box):
12574
- * bubble, radar, stock, waterfall, combo, surface, treemap, sunburst,
14059
+ * stock, waterfall, combo, surface, treemap, sunburst,
12575
14060
  * funnel, boxWhisker, histogram, regionMap, bar3D (complex 3-D shading),
12576
14061
  * error bars, trendlines, secondary axes, data tables.
12577
14062
  *
@@ -12616,6 +14101,9 @@ class ChartRendererComponent {
12616
14101
  asLine(p) {
12617
14102
  return p;
12618
14103
  }
14104
+ asPolygon(p) {
14105
+ return p;
14106
+ }
12619
14107
  asText(p) {
12620
14108
  return p;
12621
14109
  }
@@ -12697,6 +14185,7 @@ class ChartRendererComponent {
12697
14185
  [attr.text-anchor]="lbl.textAnchor"
12698
14186
  [attr.font-size]="lbl.fontSize"
12699
14187
  [attr.fill]="lbl.fill"
14188
+ [attr.dominant-baseline]="lbl.dominantBaseline ?? 'auto'"
12700
14189
  >
12701
14190
  {{ lbl.text }}
12702
14191
  </text>
@@ -12752,6 +14241,16 @@ class ChartRendererComponent {
12752
14241
  [attr.stroke-width]="asLine(prim).strokeWidth"
12753
14242
  />
12754
14243
  }
14244
+ @case ('polygon') {
14245
+ <polygon
14246
+ [attr.points]="asPolygon(prim).points"
14247
+ [attr.fill]="asPolygon(prim).fill"
14248
+ [attr.stroke]="asPolygon(prim).stroke"
14249
+ [attr.stroke-width]="asPolygon(prim).strokeWidth"
14250
+ [attr.opacity]="asPolygon(prim).opacity ?? 1"
14251
+ [attr.stroke-dasharray]="asPolygon(prim).dashArray ?? null"
14252
+ />
14253
+ }
12755
14254
  }
12756
14255
  }
12757
14256
 
@@ -12875,6 +14374,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
12875
14374
  [attr.text-anchor]="lbl.textAnchor"
12876
14375
  [attr.font-size]="lbl.fontSize"
12877
14376
  [attr.fill]="lbl.fill"
14377
+ [attr.dominant-baseline]="lbl.dominantBaseline ?? 'auto'"
12878
14378
  >
12879
14379
  {{ lbl.text }}
12880
14380
  </text>
@@ -12930,6 +14430,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
12930
14430
  [attr.stroke-width]="asLine(prim).strokeWidth"
12931
14431
  />
12932
14432
  }
14433
+ @case ('polygon') {
14434
+ <polygon
14435
+ [attr.points]="asPolygon(prim).points"
14436
+ [attr.fill]="asPolygon(prim).fill"
14437
+ [attr.stroke]="asPolygon(prim).stroke"
14438
+ [attr.stroke-width]="asPolygon(prim).strokeWidth"
14439
+ [attr.opacity]="asPolygon(prim).opacity ?? 1"
14440
+ [attr.stroke-dasharray]="asPolygon(prim).dashArray ?? null"
14441
+ />
14442
+ }
12933
14443
  }
12934
14444
  }
12935
14445
 
@@ -12971,6 +14481,435 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
12971
14481
  }]
12972
14482
  }], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }] } });
12973
14483
 
14484
+ /**
14485
+ * Pure, framework-agnostic A* orthogonal connector router.
14486
+ *
14487
+ * Angular port of the React connector-router suite:
14488
+ * packages/react/src/viewer/utils/connector-router.ts
14489
+ * packages/react/src/viewer/utils/connector-router-graph.ts
14490
+ * packages/react/src/viewer/utils/connector-router-astar.ts
14491
+ *
14492
+ * All logic is consolidated into this single file (no barrel split needed for
14493
+ * an Angular helper module). Compatible with `connector-path.ts` / the
14494
+ * `ConnectorRendererComponent`: pass the returned `Point[]` to
14495
+ * `waypointsToPathD()` to obtain the SVG `d` attribute string that the
14496
+ * component renders on a `<path>`.
14497
+ *
14498
+ * Constraints kept from the React source:
14499
+ * - No `any`.
14500
+ * - No `String.prototype.replaceAll` / named regex capture groups.
14501
+ * - No `Math.random` / `Date` — fully deterministic.
14502
+ * - No Angular / Vue / React imports.
14503
+ */
14504
+ // ---------------------------------------------------------------------------
14505
+ // Constants
14506
+ // ---------------------------------------------------------------------------
14507
+ /** Default obstacle padding in pixels. */
14508
+ const ROUTING_PADDING_DEFAULT = 12;
14509
+ /** Safety cap on A* iterations to avoid O(n²) hangs on degenerate inputs. */
14510
+ const MAX_ASTAR_ITERATIONS = 2000;
14511
+ /** Sentinel canvas size when caller does not supply one. */
14512
+ const CANVAS_SENTINEL = 100_000;
14513
+ // ---------------------------------------------------------------------------
14514
+ // Geometry helpers (exported for tests)
14515
+ // ---------------------------------------------------------------------------
14516
+ /**
14517
+ * Expand a rect by `pad` pixels on every side. Returns a new Rect.
14518
+ */
14519
+ function inflateRect(r, pad) {
14520
+ return {
14521
+ x: r.x - pad,
14522
+ y: r.y - pad,
14523
+ width: r.width + pad * 2,
14524
+ height: r.height + pad * 2,
14525
+ };
14526
+ }
14527
+ /**
14528
+ * Return true when point `p` lies strictly inside (or on the border of) `r`.
14529
+ */
14530
+ function pointInRect(p, r) {
14531
+ return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
14532
+ }
14533
+ /**
14534
+ * Return true when the axis-aligned segment `a→b` intersects rectangle `r`.
14535
+ *
14536
+ * Only horizontal and vertical segments are handled; diagonal segments are
14537
+ * treated as intersecting (safe / conservative fallback).
14538
+ */
14539
+ function segmentIntersectsRect(a, b, r) {
14540
+ const minX = Math.min(a.x, b.x);
14541
+ const maxX = Math.max(a.x, b.x);
14542
+ const minY = Math.min(a.y, b.y);
14543
+ const maxY = Math.max(a.y, b.y);
14544
+ const rRight = r.x + r.width;
14545
+ const rBottom = r.y + r.height;
14546
+ // Quick reject: bounding boxes don't overlap.
14547
+ if (maxX < r.x || minX > rRight || maxY < r.y || minY > rBottom) {
14548
+ return false;
14549
+ }
14550
+ // Horizontal segment.
14551
+ if (Math.abs(a.y - b.y) < 0.5) {
14552
+ return a.y >= r.y && a.y <= rBottom && maxX >= r.x && minX <= rRight;
14553
+ }
14554
+ // Vertical segment.
14555
+ if (Math.abs(a.x - b.x) < 0.5) {
14556
+ return a.x >= r.x && a.x <= rRight && maxY >= r.y && minY <= rBottom;
14557
+ }
14558
+ // Diagonal — conservative: treat as intersecting.
14559
+ return true;
14560
+ }
14561
+ /**
14562
+ * Return true when the direct segment `start→end` is clear of all inflated
14563
+ * obstacle rectangles.
14564
+ */
14565
+ function directPathClear(start, end, inflated) {
14566
+ for (const rect of inflated) {
14567
+ if (segmentIntersectsRect(start, end, rect)) {
14568
+ return false;
14569
+ }
14570
+ }
14571
+ return true;
14572
+ }
14573
+ /** Manhattan-distance heuristic for A*. */
14574
+ function heuristic(a, b) {
14575
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
14576
+ }
14577
+ /**
14578
+ * Stable string key for a point (rounded to nearest pixel).
14579
+ * Used as Map keys in A*.
14580
+ */
14581
+ function pointKey(p) {
14582
+ return `${Math.round(p.x)},${Math.round(p.y)}`;
14583
+ }
14584
+ // ---------------------------------------------------------------------------
14585
+ // Navigation graph construction
14586
+ // ---------------------------------------------------------------------------
14587
+ /**
14588
+ * Build the set of candidate navigation nodes for A*:
14589
+ * - Start and end points.
14590
+ * - Corners of each inflated obstacle (with a small clearance margin).
14591
+ * - Orthogonal projections of every node onto every other node's row/column.
14592
+ *
14593
+ * Nodes that fall inside an obstacle or outside the canvas are discarded.
14594
+ */
14595
+ function buildGraphNodes(start, end, inflated, canvasWidth, canvasHeight) {
14596
+ const cornerMargin = 4;
14597
+ const nodes = [start, end];
14598
+ for (const r of inflated) {
14599
+ const corners = [
14600
+ { x: r.x - cornerMargin, y: r.y - cornerMargin },
14601
+ { x: r.x + r.width + cornerMargin, y: r.y - cornerMargin },
14602
+ { x: r.x - cornerMargin, y: r.y + r.height + cornerMargin },
14603
+ { x: r.x + r.width + cornerMargin, y: r.y + r.height + cornerMargin },
14604
+ ];
14605
+ for (const c of corners) {
14606
+ if (c.x >= 0 && c.x <= canvasWidth && c.y >= 0 && c.y <= canvasHeight) {
14607
+ let blocked = false;
14608
+ for (const rect of inflated) {
14609
+ if (pointInRect(c, rect)) {
14610
+ blocked = true;
14611
+ break;
14612
+ }
14613
+ }
14614
+ if (!blocked) {
14615
+ nodes.push(c);
14616
+ }
14617
+ }
14618
+ }
14619
+ }
14620
+ // Orthogonal projections: for every existing node, add axis-aligned
14621
+ // intersection points with start and end rows/columns.
14622
+ const projections = [];
14623
+ for (const node of nodes) {
14624
+ projections.push({ x: start.x, y: node.y });
14625
+ projections.push({ x: node.x, y: start.y });
14626
+ projections.push({ x: end.x, y: node.y });
14627
+ projections.push({ x: node.x, y: end.y });
14628
+ }
14629
+ for (const p of projections) {
14630
+ if (p.x >= 0 && p.x <= canvasWidth && p.y >= 0 && p.y <= canvasHeight) {
14631
+ let blocked = false;
14632
+ for (const rect of inflated) {
14633
+ if (pointInRect(p, rect)) {
14634
+ blocked = true;
14635
+ break;
14636
+ }
14637
+ }
14638
+ if (!blocked) {
14639
+ nodes.push(p);
14640
+ }
14641
+ }
14642
+ }
14643
+ return nodes;
14644
+ }
14645
+ // ---------------------------------------------------------------------------
14646
+ // A* search
14647
+ // ---------------------------------------------------------------------------
14648
+ /**
14649
+ * Run A* over the navigation graph to find the shortest orthogonal path
14650
+ * from `start` to `end` that avoids all `inflated` obstacle rectangles.
14651
+ *
14652
+ * Returns an array of waypoints (possibly including intermediate bend points
14653
+ * when L-shaped edges are taken). Falls back to `[start, end]` when no path
14654
+ * is found within {@link MAX_ASTAR_ITERATIONS}.
14655
+ */
14656
+ function aStarOrthogonal(nodes, start, end, inflated) {
14657
+ const startKey = pointKey(start);
14658
+ const endKey = pointKey(end);
14659
+ /**
14660
+ * Check whether two nodes can be directly connected (axis-aligned or
14661
+ * L-shaped) without crossing an obstacle.
14662
+ */
14663
+ const canConnect = (a, b) => {
14664
+ const isHoriz = Math.abs(a.y - b.y) < 1;
14665
+ const isVert = Math.abs(a.x - b.x) < 1;
14666
+ if (isHoriz || isVert) {
14667
+ // Straight segment.
14668
+ for (const rect of inflated) {
14669
+ if (segmentIntersectsRect(a, b, rect)) {
14670
+ return false;
14671
+ }
14672
+ }
14673
+ return true;
14674
+ }
14675
+ // L-shaped: try both bend orientations.
14676
+ const bend1 = { x: b.x, y: a.y };
14677
+ const bend2 = { x: a.x, y: b.y };
14678
+ let path1Clear = true;
14679
+ let path2Clear = true;
14680
+ for (const rect of inflated) {
14681
+ if (path1Clear &&
14682
+ (segmentIntersectsRect(a, bend1, rect) || segmentIntersectsRect(bend1, b, rect))) {
14683
+ path1Clear = false;
14684
+ }
14685
+ if (path2Clear &&
14686
+ (segmentIntersectsRect(a, bend2, rect) || segmentIntersectsRect(bend2, b, rect))) {
14687
+ path2Clear = false;
14688
+ }
14689
+ if (!path1Clear && !path2Clear) {
14690
+ break;
14691
+ }
14692
+ }
14693
+ return path1Clear || path2Clear;
14694
+ };
14695
+ const gScore = new Map();
14696
+ const fScore = new Map();
14697
+ const cameFrom = new Map();
14698
+ /** When an L-shaped edge was used to reach this node, the bend point. */
14699
+ const bendPoint = new Map();
14700
+ gScore.set(startKey, 0);
14701
+ fScore.set(startKey, heuristic(start, end));
14702
+ const openSet = new Set([startKey]);
14703
+ const nodeMap = new Map();
14704
+ for (const n of nodes) {
14705
+ nodeMap.set(pointKey(n), n);
14706
+ }
14707
+ /** Pop the node with the lowest fScore from the open set. */
14708
+ const getLowest = () => {
14709
+ let best;
14710
+ let bestScore = Infinity;
14711
+ for (const key of openSet) {
14712
+ const score = fScore.get(key) ?? Infinity;
14713
+ if (score < bestScore) {
14714
+ bestScore = score;
14715
+ best = key;
14716
+ }
14717
+ }
14718
+ return best;
14719
+ };
14720
+ let iterations = 0;
14721
+ while (openSet.size > 0 && iterations < MAX_ASTAR_ITERATIONS) {
14722
+ iterations++;
14723
+ const currentKey = getLowest();
14724
+ if (currentKey === undefined) {
14725
+ break;
14726
+ }
14727
+ if (currentKey === endKey) {
14728
+ // Reconstruct the path from cameFrom chain.
14729
+ const path = [];
14730
+ let key = endKey;
14731
+ while (key !== undefined) {
14732
+ const node = nodeMap.get(key);
14733
+ if (node !== undefined) {
14734
+ const bp = bendPoint.get(key);
14735
+ if (bp !== undefined && bp !== null) {
14736
+ path.unshift(node);
14737
+ path.unshift(bp);
14738
+ }
14739
+ else {
14740
+ path.unshift(node);
14741
+ }
14742
+ }
14743
+ key = cameFrom.get(key);
14744
+ }
14745
+ return path;
14746
+ }
14747
+ openSet.delete(currentKey);
14748
+ const current = nodeMap.get(currentKey);
14749
+ if (current === undefined) {
14750
+ continue;
14751
+ }
14752
+ for (const neighbor of nodes) {
14753
+ const neighborKey = pointKey(neighbor);
14754
+ if (neighborKey === currentKey) {
14755
+ continue;
14756
+ }
14757
+ if (!canConnect(current, neighbor)) {
14758
+ continue;
14759
+ }
14760
+ const isHoriz = Math.abs(current.y - neighbor.y) < 1;
14761
+ const isVert = Math.abs(current.x - neighbor.x) < 1;
14762
+ let dist;
14763
+ let bp = null;
14764
+ if (isHoriz || isVert) {
14765
+ dist = heuristic(current, neighbor);
14766
+ }
14767
+ else {
14768
+ // L-shaped: pick the shorter valid bend.
14769
+ const bend1 = { x: neighbor.x, y: current.y };
14770
+ const bend2 = { x: current.x, y: neighbor.y };
14771
+ let use1 = true;
14772
+ for (const rect of inflated) {
14773
+ if (segmentIntersectsRect(current, bend1, rect) ||
14774
+ segmentIntersectsRect(bend1, neighbor, rect)) {
14775
+ use1 = false;
14776
+ break;
14777
+ }
14778
+ }
14779
+ bp = use1 ? bend1 : bend2;
14780
+ dist =
14781
+ Math.abs(current.x - bp.x) +
14782
+ Math.abs(current.y - bp.y) +
14783
+ Math.abs(bp.x - neighbor.x) +
14784
+ Math.abs(bp.y - neighbor.y);
14785
+ }
14786
+ const tentativeG = (gScore.get(currentKey) ?? Infinity) + dist;
14787
+ if (tentativeG < (gScore.get(neighborKey) ?? Infinity)) {
14788
+ cameFrom.set(neighborKey, currentKey);
14789
+ bendPoint.set(neighborKey, bp);
14790
+ gScore.set(neighborKey, tentativeG);
14791
+ fScore.set(neighborKey, tentativeG + heuristic(neighbor, end));
14792
+ openSet.add(neighborKey);
14793
+ }
14794
+ }
14795
+ }
14796
+ // No path found — fall back to a direct two-point path.
14797
+ return [start, end];
14798
+ }
14799
+ // ---------------------------------------------------------------------------
14800
+ // Path simplification
14801
+ // ---------------------------------------------------------------------------
14802
+ /**
14803
+ * Remove collinear intermediate waypoints from a path.
14804
+ *
14805
+ * A waypoint is dropped when the previous and next waypoints share the same
14806
+ * axis as both flanking segments (i.e. three consecutive collinear points).
14807
+ * This keeps the output minimal while preserving every directional change.
14808
+ */
14809
+ function simplifyPath(points) {
14810
+ if (points.length <= 2) {
14811
+ return [...points];
14812
+ }
14813
+ const result = [points[0]];
14814
+ for (let i = 1; i < points.length - 1; i++) {
14815
+ const prev = result[result.length - 1];
14816
+ const curr = points[i];
14817
+ const next = points[i + 1];
14818
+ const sameX = Math.abs(prev.x - curr.x) < 1 && Math.abs(curr.x - next.x) < 1;
14819
+ const sameY = Math.abs(prev.y - curr.y) < 1 && Math.abs(curr.y - next.y) < 1;
14820
+ // Drop only when strictly collinear on one axis.
14821
+ if (!sameX && !sameY) {
14822
+ result.push(curr);
14823
+ }
14824
+ else if (!sameX || !sameY) {
14825
+ // One axis matches — still a direction change if the other doesn't.
14826
+ if (!(sameX || sameY)) {
14827
+ result.push(curr);
14828
+ }
14829
+ }
14830
+ // else: fully collinear on both axes → drop.
14831
+ }
14832
+ result.push(points[points.length - 1]);
14833
+ return result;
14834
+ }
14835
+ // ---------------------------------------------------------------------------
14836
+ // Public routing API
14837
+ // ---------------------------------------------------------------------------
14838
+ /**
14839
+ * Route an orthogonal connector between `start` and `end`, avoiding all
14840
+ * `obstacles`. Returns a list of waypoints (including the start and end
14841
+ * points) that form an axis-aligned polyline.
14842
+ *
14843
+ * Strategy (fast-path first, A* as fallback):
14844
+ * 1. No obstacles → return `[start, end]` directly.
14845
+ * 2. Direct line clear → return `[start, end]`.
14846
+ * 3. Single horizontal elbow (`start → (end.x, start.y) → end`) clear → use it.
14847
+ * 4. Single vertical elbow (`start → (start.x, end.y) → end`) clear → use it.
14848
+ * 5. Full A* search on the navigation graph.
14849
+ */
14850
+ function routeOrthogonalConnector(start, end, obstacles, opts) {
14851
+ const padding = opts?.padding ?? ROUTING_PADDING_DEFAULT;
14852
+ const canvasWidth = opts?.canvasWidth ?? CANVAS_SENTINEL;
14853
+ const canvasHeight = opts?.canvasHeight ?? CANVAS_SENTINEL;
14854
+ if (obstacles.length === 0) {
14855
+ return [start, end];
14856
+ }
14857
+ const inflated = obstacles.map((r) => inflateRect(r, padding));
14858
+ if (directPathClear(start, end, inflated)) {
14859
+ return [start, end];
14860
+ }
14861
+ // Try single horizontal elbow.
14862
+ const midH = { x: end.x, y: start.y };
14863
+ const midV = { x: start.x, y: end.y };
14864
+ let elbowHClear = true;
14865
+ let elbowVClear = true;
14866
+ for (const rect of inflated) {
14867
+ if (elbowHClear &&
14868
+ (segmentIntersectsRect(start, midH, rect) || segmentIntersectsRect(midH, end, rect))) {
14869
+ elbowHClear = false;
14870
+ }
14871
+ if (elbowVClear &&
14872
+ (segmentIntersectsRect(start, midV, rect) || segmentIntersectsRect(midV, end, rect))) {
14873
+ elbowVClear = false;
14874
+ }
14875
+ if (!elbowHClear && !elbowVClear) {
14876
+ break;
14877
+ }
14878
+ }
14879
+ if (elbowHClear) {
14880
+ return [start, midH, end];
14881
+ }
14882
+ if (elbowVClear) {
14883
+ return [start, midV, end];
14884
+ }
14885
+ // Full A* search.
14886
+ const nodes = buildGraphNodes(start, end, inflated, canvasWidth, canvasHeight);
14887
+ const path = aStarOrthogonal(nodes, start, end, inflated);
14888
+ return simplifyPath(path);
14889
+ }
14890
+ // ---------------------------------------------------------------------------
14891
+ // SVG path serialisation
14892
+ // ---------------------------------------------------------------------------
14893
+ /**
14894
+ * Convert an array of waypoints to an SVG `path` `d` attribute string.
14895
+ *
14896
+ * Returns an empty string for an empty waypoint array, `"M x y"` for a
14897
+ * single point, and `"M x y L x1 y1 …"` for a polyline.
14898
+ *
14899
+ * This output is compatible with the `pathD` field on `ConnectorGeometry`
14900
+ * from `connector-path.ts` and can be bound directly to a `<path [attr.d]>`.
14901
+ */
14902
+ function waypointsToPathD(waypoints) {
14903
+ if (waypoints.length === 0) {
14904
+ return '';
14905
+ }
14906
+ const parts = [`M${waypoints[0].x},${waypoints[0].y}`];
14907
+ for (let i = 1; i < waypoints.length; i++) {
14908
+ parts.push(`L${waypoints[i].x},${waypoints[i].y}`);
14909
+ }
14910
+ return parts.join(' ');
14911
+ }
14912
+
12974
14913
  /**
12975
14914
  * Pure, framework-agnostic helpers for rendering connector elements.
12976
14915
  *
@@ -12990,7 +14929,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
12990
14929
  * This is a pure function: no side-effects, no Angular/Vue/React imports.
12991
14930
  * The component calls this once per change-detection cycle inside a `computed()`.
12992
14931
  */
12993
- function buildConnectorGeometry(element, zIndex) {
14932
+ function buildConnectorGeometry(element, zIndex, routing) {
12994
14933
  const ss = hasShapeProperties(element) ? element.shapeStyle : undefined;
12995
14934
  const strokeWidth = Math.max(0, ss?.strokeWidth ?? 2);
12996
14935
  const strokeColor = ss?.strokeColor ?? DEFAULT_STROKE_COLOR;
@@ -13003,7 +14942,26 @@ function buildConnectorGeometry(element, zIndex) {
13003
14942
  const x2 = element.flipHorizontal ? 0 : svgW;
13004
14943
  const y2 = element.flipVertical ? 0 : svgH;
13005
14944
  const shapeType = element.shapeType;
13006
- const pathD = buildConnectorPathD(shapeType, x1, y1, x2, y2, connectorBendFraction(element));
14945
+ let pathD = buildConnectorPathD(shapeType, x1, y1, x2, y2, connectorBendFraction(element));
14946
+ // Obstacle-avoiding A* routing for bent connectors. Routes in absolute slide
14947
+ // coordinates (so it can detour outside the connector's own bounding box —
14948
+ // the SVG uses `overflow: visible`), then translates waypoints back to
14949
+ // element-local space for the path data.
14950
+ if (routing &&
14951
+ routing.obstacles.length > 0 &&
14952
+ connectorKind(shapeType) === 'bent' &&
14953
+ (element.width > 0 || element.height > 0)) {
14954
+ const start = { x: element.x + x1, y: element.y + y1 };
14955
+ const end = { x: element.x + x2, y: element.y + y2 };
14956
+ const waypoints = routeOrthogonalConnector(start, end, routing.obstacles, {
14957
+ canvasWidth: routing.canvasWidth,
14958
+ canvasHeight: routing.canvasHeight,
14959
+ });
14960
+ if (waypoints.length > 2) {
14961
+ const local = waypoints.map((p) => ({ x: p.x - element.x, y: p.y - element.y }));
14962
+ pathD = waypointsToPathD(local);
14963
+ }
14964
+ }
13007
14965
  const markerSeed = element.id.replace(/[^a-zA-Z0-9_-]/gu, '_');
13008
14966
  const startMarkerId = `${markerSeed}-start`;
13009
14967
  const endMarkerId = `${markerSeed}-end`;
@@ -13162,6 +15120,200 @@ function buildWrapperStyle(element, zIndex) {
13162
15120
  return parts.join(';');
13163
15121
  }
13164
15122
 
15123
+ /**
15124
+ * Connector text overlay — Angular port of the Vue `ConnectorTextOverlay.vue`
15125
+ * (packages/vue/src/viewer/components/ConnectorTextOverlay.vue).
15126
+ *
15127
+ * Renders a connector's label text centred over the connector's bounding box.
15128
+ * PowerPoint allows authors to attach a text run to a connector element
15129
+ * (`<p:cxnSp>` with a non-empty `<p:txBody>`); the label is painted on top of
15130
+ * the connector path, centred both horizontally and vertically within the
15131
+ * element's bounding box.
15132
+ *
15133
+ * The overlay is an absolutely-positioned flex container rendered as a sibling
15134
+ * of the SVG connector — NOT part of the SVG — so per-segment rich text
15135
+ * renders with standard HTML text layout. It is `pointer-events: none` and
15136
+ * never intercepts selection or hit-testing on the connector beneath it.
15137
+ *
15138
+ * ### Pure label-geometry helper
15139
+ * All positioning / style math lives in the exported pure functions
15140
+ * `buildOverlayContainerStyle`, `buildOverlayBlockStyle`, and `buildSegmentStyle`
15141
+ * so they can be unit-tested without TestBed (see
15142
+ * `connector-text-overlay.component.test.ts`).
15143
+ */
15144
+ // ---------------------------------------------------------------------------
15145
+ // Pure exported helpers (tested independently, no Angular dependency)
15146
+ // ---------------------------------------------------------------------------
15147
+ /**
15148
+ * Inline style string for the outer overlay container.
15149
+ *
15150
+ * The container fills the connector bounding box, uses flexbox to centre
15151
+ * content, and is `pointer-events: none`.
15152
+ *
15153
+ * @param align - OOXML paragraph alignment token. Variants `justLow`, `dist`,
15154
+ * and `thaiDist` collapse to `justify`; every other value passes through;
15155
+ * absent → `center` (connector-label convention).
15156
+ */
15157
+ function buildOverlayContainerStyle(align) {
15158
+ let textAlign;
15159
+ if (align === 'justLow' || align === 'dist' || align === 'thaiDist') {
15160
+ textAlign = 'justify';
15161
+ }
15162
+ else if (align !== undefined) {
15163
+ textAlign = align;
15164
+ }
15165
+ else {
15166
+ textAlign = 'center';
15167
+ }
15168
+ return [
15169
+ 'position:absolute',
15170
+ 'inset:0',
15171
+ 'display:flex',
15172
+ 'align-items:center',
15173
+ 'justify-content:center',
15174
+ 'overflow:hidden',
15175
+ 'pointer-events:none',
15176
+ `text-align:${textAlign}`,
15177
+ ].join(';');
15178
+ }
15179
+ /**
15180
+ * Inline style string for the inner text-block `<div>`.
15181
+ *
15182
+ * Applies paragraph-level defaults: font family, size, colour, weight, style,
15183
+ * and decoration from `textStyle`, with sensible fall-backs for connector
15184
+ * labels (10pt, black, normal).
15185
+ */
15186
+ function buildOverlayBlockStyle(textStyle) {
15187
+ const ts = textStyle;
15188
+ const parts = [
15189
+ `font-family:${ts?.fontFamily ?? 'inherit'}`,
15190
+ `font-size:${ts?.fontSize !== undefined ? `${ts.fontSize}pt` : '10pt'}`,
15191
+ `color:${ts?.color ?? '#000000'}`,
15192
+ `font-weight:${ts?.bold ? 'bold' : 'normal'}`,
15193
+ `font-style:${ts?.italic ? 'italic' : 'normal'}`,
15194
+ `text-decoration:${ts?.underline ? 'underline' : 'none'}`,
15195
+ 'padding:0 4px',
15196
+ 'white-space:pre-wrap',
15197
+ 'line-height:1.2',
15198
+ 'max-width:100%',
15199
+ ];
15200
+ return parts.join(';');
15201
+ }
15202
+ /**
15203
+ * Inline style string for a single text-run `<span>`.
15204
+ *
15205
+ * Run-level properties override paragraph-level properties where both are
15206
+ * present. Falls back to `textStyle` (paragraph defaults) for each property.
15207
+ */
15208
+ function buildSegmentStyle(segment, textStyle) {
15209
+ const s = segment.style;
15210
+ const ts = textStyle;
15211
+ const parts = [
15212
+ `font-family:${s?.fontFamily ?? ts?.fontFamily ?? 'inherit'}`,
15213
+ `color:${s?.color ?? ts?.color ?? '#000000'}`,
15214
+ `font-weight:${s?.bold ? 'bold' : ts?.bold ? 'bold' : 'normal'}`,
15215
+ `font-style:${s?.italic ? 'italic' : ts?.italic ? 'italic' : 'normal'}`,
15216
+ `text-decoration:${s?.underline ? 'underline' : 'none'}`,
15217
+ ];
15218
+ if (s?.fontSize !== undefined) {
15219
+ parts.push(`font-size:${s.fontSize}pt`);
15220
+ }
15221
+ return parts.join(';');
15222
+ }
15223
+ // ---------------------------------------------------------------------------
15224
+ // Component
15225
+ // ---------------------------------------------------------------------------
15226
+ /**
15227
+ * `<pptx-connector-text-overlay>` — renders a connector's text label.
15228
+ *
15229
+ * **Usage** (inside `ConnectorRendererComponent`'s host wrapper `<div>`):
15230
+ * ```html
15231
+ * @if (hasLabel()) {
15232
+ * <pptx-connector-text-overlay
15233
+ * [text]="element().text"
15234
+ * [segments]="element().textSegments"
15235
+ * [textStyle]="element().textStyle"
15236
+ * />
15237
+ * }
15238
+ * ```
15239
+ *
15240
+ * All inputs are optional; the component renders nothing when `text` is falsy
15241
+ * or `segments` is empty.
15242
+ */
15243
+ class ConnectorTextOverlayComponent {
15244
+ /**
15245
+ * Trimmed plain-text label. When falsy the overlay is not rendered.
15246
+ * This is the `text` property from `ConnectorPptxElement`.
15247
+ */
15248
+ text = input(undefined, /* @ts-ignore */
15249
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
15250
+ /**
15251
+ * Per-run rich-text segments from `ConnectorPptxElement.textSegments`.
15252
+ * When absent or empty the overlay is not rendered.
15253
+ */
15254
+ segments = input(undefined, /* @ts-ignore */
15255
+ ...(ngDevMode ? [{ debugName: "segments" }] : /* istanbul ignore next */ []));
15256
+ /**
15257
+ * Paragraph-level text style from `ConnectorPptxElement.textStyle`.
15258
+ * Controls alignment, default font, colour, etc.
15259
+ */
15260
+ textStyle = input(undefined, /* @ts-ignore */
15261
+ ...(ngDevMode ? [{ debugName: "textStyle" }] : /* istanbul ignore next */ []));
15262
+ // -----------------------------------------------------------------------
15263
+ // Derived signals
15264
+ // -----------------------------------------------------------------------
15265
+ /** True when there is a non-empty label to display. */
15266
+ hasText = computed(() => Boolean(this.text()) && this.segments() !== undefined && (this.segments()?.length ?? 0) > 0, /* @ts-ignore */
15267
+ ...(ngDevMode ? [{ debugName: "hasText" }] : /* istanbul ignore next */ []));
15268
+ /** Inline style for the outer flex container. */
15269
+ containerStyle = computed(() => buildOverlayContainerStyle(this.textStyle()?.align), /* @ts-ignore */
15270
+ ...(ngDevMode ? [{ debugName: "containerStyle" }] : /* istanbul ignore next */ []));
15271
+ /** Inline style for the inner paragraph block. */
15272
+ blockStyle = computed(() => buildOverlayBlockStyle(this.textStyle()), /* @ts-ignore */
15273
+ ...(ngDevMode ? [{ debugName: "blockStyle" }] : /* istanbul ignore next */ []));
15274
+ /** Pre-computed segments with their individual inline style strings. */
15275
+ styledSegments = computed(() => {
15276
+ const segs = this.segments() ?? [];
15277
+ const ts = this.textStyle();
15278
+ return segs
15279
+ .filter((s) => !s.isParagraphBreak)
15280
+ .map((s) => ({ text: s.text, style: buildSegmentStyle(s, ts) }));
15281
+ }, /* @ts-ignore */
15282
+ ...(ngDevMode ? [{ debugName: "styledSegments" }] : /* istanbul ignore next */ []));
15283
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ConnectorTextOverlayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
15284
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: ConnectorTextOverlayComponent, isStandalone: true, selector: "pptx-connector-text-overlay", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: false, transformFunction: null }, segments: { classPropertyName: "segments", publicName: "segments", isSignal: true, isRequired: false, transformFunction: null }, textStyle: { classPropertyName: "textStyle", publicName: "textStyle", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
15285
+ @if (hasText()) {
15286
+ <div class="pptx-ng-connector-text" [style]="containerStyle()">
15287
+ <div class="pptx-ng-connector-text__block" [style]="blockStyle()">
15288
+ @for (seg of styledSegments(); track $index) {
15289
+ <span class="pptx-ng-connector-text__run" [style]="seg.style">{{ seg.text }}</span>
15290
+ }
15291
+ </div>
15292
+ </div>
15293
+ }
15294
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
15295
+ }
15296
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ConnectorTextOverlayComponent, decorators: [{
15297
+ type: Component,
15298
+ args: [{
15299
+ selector: 'pptx-connector-text-overlay',
15300
+ standalone: true,
15301
+ changeDetection: ChangeDetectionStrategy.OnPush,
15302
+ imports: [],
15303
+ template: `
15304
+ @if (hasText()) {
15305
+ <div class="pptx-ng-connector-text" [style]="containerStyle()">
15306
+ <div class="pptx-ng-connector-text__block" [style]="blockStyle()">
15307
+ @for (seg of styledSegments(); track $index) {
15308
+ <span class="pptx-ng-connector-text__run" [style]="seg.style">{{ seg.text }}</span>
15309
+ }
15310
+ </div>
15311
+ </div>
15312
+ }
15313
+ `,
15314
+ }]
15315
+ }], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: false }] }], segments: [{ type: i0.Input, args: [{ isSignal: true, alias: "segments", required: false }] }], textStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "textStyle", required: false }] }] } });
15316
+
13165
15317
  /**
13166
15318
  * ConnectorRendererComponent — Angular port of the Vue `ConnectorRenderer.vue`
13167
15319
  * (and the React `ConnectorElementRenderer`, basic subset).
@@ -13171,25 +15323,53 @@ function buildWrapperStyle(element, zIndex) {
13171
15323
  * is baked into the endpoints (not a CSS transform) so arrowheads point the
13172
15324
  * right way.
13173
15325
  *
13174
- * All path/style math lives in `connector-path.ts` (pure TS, no Angular
13175
- * dependency) so it can be unit-tested without TestBed.
15326
+ * Renders straight (`<line>`), bent (elbow) and curved (Bézier) connectors via
15327
+ * `connector-path.ts`. When obstacle rects are supplied (`obstacles` input,
15328
+ * absolute slide coords), bent connectors are routed around them with an A*
15329
+ * orthogonal router. A connector's optional text label is painted on top via
15330
+ * `ConnectorTextOverlayComponent`.
15331
+ *
15332
+ * All path/style math lives in `connector-path.ts` / `connector-routing.ts`
15333
+ * (pure TS, no Angular dependency) so it can be unit-tested without TestBed.
13176
15334
  *
13177
- * Not yet ported (TODO, see PORTING.md): bent/curved connector routing
13178
- * (`getConnectorPathGeometry`), compound lines, connector text overlay, line
13179
- * shadows/glow. Bent/curved connectors currently fall back to a straight line.
15335
+ * Not yet ported (TODO, see PORTING.md): compound (double/triple) lines, line
15336
+ * shadows/glow.
13180
15337
  */
13181
15338
  class ConnectorRendererComponent {
13182
15339
  element = input.required(/* @ts-ignore */
13183
15340
  ...(ngDevMode ? [{ debugName: "element" }] : /* istanbul ignore next */ []));
13184
15341
  zIndex = input(0, /* @ts-ignore */
13185
15342
  ...(ngDevMode ? [{ debugName: "zIndex" }] : /* istanbul ignore next */ []));
15343
+ /** Obstacle rects (absolute slide coords) for A* routing of bent connectors. */
15344
+ obstacles = input([], /* @ts-ignore */
15345
+ ...(ngDevMode ? [{ debugName: "obstacles" }] : /* istanbul ignore next */ []));
15346
+ canvasWidth = input(0, /* @ts-ignore */
15347
+ ...(ngDevMode ? [{ debugName: "canvasWidth" }] : /* istanbul ignore next */ []));
15348
+ canvasHeight = input(0, /* @ts-ignore */
15349
+ ...(ngDevMode ? [{ debugName: "canvasHeight" }] : /* istanbul ignore next */ []));
13186
15350
  /** All derived geometry, recomputed on every input change. */
13187
- geo = computed(() => buildConnectorGeometry(this.element(), this.zIndex()), /* @ts-ignore */
15351
+ geo = computed(() => {
15352
+ const obstacles = this.obstacles();
15353
+ const routing = obstacles.length > 0
15354
+ ? { obstacles, canvasWidth: this.canvasWidth(), canvasHeight: this.canvasHeight() }
15355
+ : undefined;
15356
+ return buildConnectorGeometry(this.element(), this.zIndex(), routing);
15357
+ }, /* @ts-ignore */
13188
15358
  ...(ngDevMode ? [{ debugName: "geo" }] : /* istanbul ignore next */ []));
13189
15359
  viewBox = computed(() => `0 0 ${this.geo().svgW} ${this.geo().svgH}`, /* @ts-ignore */
13190
15360
  ...(ngDevMode ? [{ debugName: "viewBox" }] : /* istanbul ignore next */ []));
15361
+ // Connectors carry an optional text label (PptxTextProperties). Narrow the
15362
+ // union once here so the template can bind the overlay inputs.
15363
+ textProps = computed(() => this.element(), /* @ts-ignore */
15364
+ ...(ngDevMode ? [{ debugName: "textProps" }] : /* istanbul ignore next */ []));
15365
+ connectorText = computed(() => this.textProps().text, /* @ts-ignore */
15366
+ ...(ngDevMode ? [{ debugName: "connectorText" }] : /* istanbul ignore next */ []));
15367
+ connectorSegments = computed(() => this.textProps().textSegments, /* @ts-ignore */
15368
+ ...(ngDevMode ? [{ debugName: "connectorSegments" }] : /* istanbul ignore next */ []));
15369
+ connectorTextStyle = computed(() => this.textProps().textStyle, /* @ts-ignore */
15370
+ ...(ngDevMode ? [{ debugName: "connectorTextStyle" }] : /* istanbul ignore next */ []));
13191
15371
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ConnectorRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
13192
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: ConnectorRendererComponent, isStandalone: true, selector: "pptx-connector-renderer", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
15372
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: ConnectorRendererComponent, isStandalone: true, selector: "pptx-connector-renderer", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null }, obstacles: { classPropertyName: "obstacles", publicName: "obstacles", isSignal: true, isRequired: false, transformFunction: null }, canvasWidth: { classPropertyName: "canvasWidth", publicName: "canvasWidth", isSignal: true, isRequired: false, transformFunction: null }, canvasHeight: { classPropertyName: "canvasHeight", publicName: "canvasHeight", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
13193
15373
  <div
13194
15374
  class="pptx-ng-element pptx-ng-connector"
13195
15375
  [style]="geo().wrapperStyle"
@@ -13268,8 +15448,13 @@ class ConnectorRendererComponent {
13268
15448
  />
13269
15449
  }
13270
15450
  </svg>
15451
+ <pptx-connector-text-overlay
15452
+ [text]="connectorText()"
15453
+ [segments]="connectorSegments()"
15454
+ [textStyle]="connectorTextStyle()"
15455
+ />
13271
15456
  </div>
13272
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
15457
+ `, isInline: true, dependencies: [{ kind: "component", type: ConnectorTextOverlayComponent, selector: "pptx-connector-text-overlay", inputs: ["text", "segments", "textStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
13273
15458
  }
13274
15459
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ConnectorRendererComponent, decorators: [{
13275
15460
  type: Component,
@@ -13277,7 +15462,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
13277
15462
  selector: 'pptx-connector-renderer',
13278
15463
  standalone: true,
13279
15464
  changeDetection: ChangeDetectionStrategy.OnPush,
13280
- imports: [],
15465
+ imports: [ConnectorTextOverlayComponent],
13281
15466
  template: `
13282
15467
  <div
13283
15468
  class="pptx-ng-element pptx-ng-connector"
@@ -13357,10 +15542,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
13357
15542
  />
13358
15543
  }
13359
15544
  </svg>
15545
+ <pptx-connector-text-overlay
15546
+ [text]="connectorText()"
15547
+ [segments]="connectorSegments()"
15548
+ [textStyle]="connectorTextStyle()"
15549
+ />
13360
15550
  </div>
13361
15551
  `,
13362
15552
  }]
13363
- }], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }] } });
15553
+ }], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }], obstacles: [{ type: i0.Input, args: [{ isSignal: true, alias: "obstacles", required: false }] }], canvasWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "canvasWidth", required: false }] }], canvasHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "canvasHeight", required: false }] }] } });
13364
15554
 
13365
15555
  /**
13366
15556
  * Scalar viewer defaults — shared across the React, Vue, and Angular bindings
@@ -18000,6 +20190,13 @@ class ElementRendererComponent {
18000
20190
  ...(ngDevMode ? [{ debugName: "mediaDataUrls" }] : /* istanbul ignore next */ []));
18001
20191
  zIndex = input(0, /* @ts-ignore */
18002
20192
  ...(ngDevMode ? [{ debugName: "zIndex" }] : /* istanbul ignore next */ []));
20193
+ /** Obstacle rects (absolute slide coords) for connector A* routing. */
20194
+ obstacles = input([], /* @ts-ignore */
20195
+ ...(ngDevMode ? [{ debugName: "obstacles" }] : /* istanbul ignore next */ []));
20196
+ canvasWidth = input(0, /* @ts-ignore */
20197
+ ...(ngDevMode ? [{ debugName: "canvasWidth" }] : /* istanbul ignore next */ []));
20198
+ canvasHeight = input(0, /* @ts-ignore */
20199
+ ...(ngDevMode ? [{ debugName: "canvasHeight" }] : /* istanbul ignore next */ []));
18003
20200
  containerStyle = computed(() => getContainerStyle(this.element(), this.zIndex()), /* @ts-ignore */
18004
20201
  ...(ngDevMode ? [{ debugName: "containerStyle" }] : /* istanbul ignore next */ []));
18005
20202
  shapeContainerStyle = computed(() => ({
@@ -18119,10 +20316,16 @@ class ElementRendererComponent {
18119
20316
  return style;
18120
20317
  }
18121
20318
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ElementRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
18122
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: ElementRendererComponent, isStandalone: true, selector: "pptx-element-renderer", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, mediaDataUrls: { classPropertyName: "mediaDataUrls", publicName: "mediaDataUrls", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
20319
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: ElementRendererComponent, isStandalone: true, selector: "pptx-element-renderer", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, mediaDataUrls: { classPropertyName: "mediaDataUrls", publicName: "mediaDataUrls", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null }, obstacles: { classPropertyName: "obstacles", publicName: "obstacles", isSignal: true, isRequired: false, transformFunction: null }, canvasWidth: { classPropertyName: "canvasWidth", publicName: "canvasWidth", isSignal: true, isRequired: false, transformFunction: null }, canvasHeight: { classPropertyName: "canvasHeight", publicName: "canvasHeight", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
18123
20320
  @switch (true) {
18124
20321
  @case (element().type === 'connector') {
18125
- <pptx-connector-renderer [element]="element()" [zIndex]="zIndex()" />
20322
+ <pptx-connector-renderer
20323
+ [element]="element()"
20324
+ [zIndex]="zIndex()"
20325
+ [obstacles]="obstacles()"
20326
+ [canvasWidth]="canvasWidth()"
20327
+ [canvasHeight]="canvasHeight()"
20328
+ />
18126
20329
  }
18127
20330
  @case (element().type === 'ink') {
18128
20331
  <pptx-ink-renderer
@@ -18277,7 +20480,7 @@ class ElementRendererComponent {
18277
20480
  </div>
18278
20481
  }
18279
20482
  }
18280
- `, isInline: true, dependencies: [{ kind: "component", type: ElementRendererComponent, selector: "pptx-element-renderer", inputs: ["element", "mediaDataUrls", "zIndex"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: ConnectorRendererComponent, selector: "pptx-connector-renderer", inputs: ["element", "zIndex"] }, { kind: "component", type: TableRendererComponent, selector: "pptx-table-renderer", inputs: ["element"] }, { kind: "component", type: ChartRendererComponent, selector: "pptx-chart-renderer", inputs: ["element"] }, { kind: "component", type: SmartArtRendererComponent, selector: "pptx-smart-art-renderer", inputs: ["element", "zIndex"] }, { kind: "component", type: InkRendererComponent, selector: "pptx-ink-renderer", inputs: ["element", "zIndex", "mediaDataUrls"] }, { kind: "component", type: OleRendererComponent, selector: "pptx-ole-renderer", inputs: ["element", "zIndex"] }, { kind: "component", type: Model3DRendererComponent, selector: "pptx-model3d-renderer", inputs: ["element", "zIndex", "mediaDataUrls"] }, { kind: "component", type: ZoomRendererComponent, selector: "pptx-zoom-renderer", inputs: ["element", "zIndex", "mediaDataUrls"] }, { kind: "component", type: EquationRendererComponent, selector: "pptx-equation-renderer", inputs: ["equationXml", "equationNumber"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
20483
+ `, isInline: true, dependencies: [{ kind: "component", type: ElementRendererComponent, selector: "pptx-element-renderer", inputs: ["element", "mediaDataUrls", "zIndex", "obstacles", "canvasWidth", "canvasHeight"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: ConnectorRendererComponent, selector: "pptx-connector-renderer", inputs: ["element", "zIndex", "obstacles", "canvasWidth", "canvasHeight"] }, { kind: "component", type: TableRendererComponent, selector: "pptx-table-renderer", inputs: ["element"] }, { kind: "component", type: ChartRendererComponent, selector: "pptx-chart-renderer", inputs: ["element"] }, { kind: "component", type: SmartArtRendererComponent, selector: "pptx-smart-art-renderer", inputs: ["element", "zIndex"] }, { kind: "component", type: InkRendererComponent, selector: "pptx-ink-renderer", inputs: ["element", "zIndex", "mediaDataUrls"] }, { kind: "component", type: OleRendererComponent, selector: "pptx-ole-renderer", inputs: ["element", "zIndex"] }, { kind: "component", type: Model3DRendererComponent, selector: "pptx-model3d-renderer", inputs: ["element", "zIndex", "mediaDataUrls"] }, { kind: "component", type: ZoomRendererComponent, selector: "pptx-zoom-renderer", inputs: ["element", "zIndex", "mediaDataUrls"] }, { kind: "component", type: EquationRendererComponent, selector: "pptx-equation-renderer", inputs: ["equationXml", "equationNumber"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
18281
20484
  }
18282
20485
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ElementRendererComponent, decorators: [{
18283
20486
  type: Component,
@@ -18300,7 +20503,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
18300
20503
  template: `
18301
20504
  @switch (true) {
18302
20505
  @case (element().type === 'connector') {
18303
- <pptx-connector-renderer [element]="element()" [zIndex]="zIndex()" />
20506
+ <pptx-connector-renderer
20507
+ [element]="element()"
20508
+ [zIndex]="zIndex()"
20509
+ [obstacles]="obstacles()"
20510
+ [canvasWidth]="canvasWidth()"
20511
+ [canvasHeight]="canvasHeight()"
20512
+ />
18304
20513
  }
18305
20514
  @case (element().type === 'ink') {
18306
20515
  <pptx-ink-renderer
@@ -18457,7 +20666,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
18457
20666
  }
18458
20667
  `,
18459
20668
  }]
18460
- }], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], mediaDataUrls: [{ type: i0.Input, args: [{ isSignal: true, alias: "mediaDataUrls", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }] } });
20669
+ }], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], mediaDataUrls: [{ type: i0.Input, args: [{ isSignal: true, alias: "mediaDataUrls", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }], obstacles: [{ type: i0.Input, args: [{ isSignal: true, alias: "obstacles", required: false }] }], canvasWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "canvasWidth", required: false }] }], canvasHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "canvasHeight", required: false }] }] } });
18461
20670
 
18462
20671
  /** Default slide stage colour when a slide carries no usable background. */
18463
20672
  const DEFAULT_SLIDE_BACKGROUND = '#ffffff';
@@ -18794,6 +21003,15 @@ class SlideCanvasComponent {
18794
21003
  }
18795
21004
  elements = computed(() => this.slide()?.elements ?? [], /* @ts-ignore */
18796
21005
  ...(ngDevMode ? [{ debugName: "elements" }] : /* istanbul ignore next */ []));
21006
+ /**
21007
+ * Obstacle rects (absolute slide coords) for connector A* routing: every
21008
+ * non-connector element with a positive footprint. Bent connectors detour
21009
+ * around these instead of cutting straight through neighbouring shapes.
21010
+ */
21011
+ connectorObstacles = computed(() => this.elements()
21012
+ .filter((e) => e.type !== 'connector' && e.width > 0 && e.height > 0)
21013
+ .map((e) => ({ x: e.x, y: e.y, width: e.width, height: e.height })), /* @ts-ignore */
21014
+ ...(ngDevMode ? [{ debugName: "connectorObstacles" }] : /* istanbul ignore next */ []));
18797
21015
  /** Bounding boxes (stage coords) for the selected elements. */
18798
21016
  selectionBoxes = computed(() => {
18799
21017
  const selected = new Set(this.selectedIds());
@@ -19158,6 +21376,9 @@ class SlideCanvasComponent {
19158
21376
  [element]="element"
19159
21377
  [mediaDataUrls]="mediaDataUrls()"
19160
21378
  [zIndex]="i"
21379
+ [obstacles]="connectorObstacles()"
21380
+ [canvasWidth]="canvasSize().width"
21381
+ [canvasHeight]="canvasSize().height"
19161
21382
  />
19162
21383
  }
19163
21384
  @for (box of selectionBoxes(); track box.id) {
@@ -19224,7 +21445,7 @@ class SlideCanvasComponent {
19224
21445
  </div>
19225
21446
  </div>
19226
21447
  </div>
19227
- `, isInline: true, styles: [".pptx-ng-canvas-stage.is-editable{touch-action:none}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: ElementRendererComponent, selector: "pptx-element-renderer", inputs: ["element", "mediaDataUrls", "zIndex"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
21448
+ `, isInline: true, styles: [".pptx-ng-canvas-stage.is-editable{touch-action:none}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: ElementRendererComponent, selector: "pptx-element-renderer", inputs: ["element", "mediaDataUrls", "zIndex", "obstacles", "canvasWidth", "canvasHeight"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
19228
21449
  }
19229
21450
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SlideCanvasComponent, decorators: [{
19230
21451
  type: Component,
@@ -19247,6 +21468,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
19247
21468
  [element]="element"
19248
21469
  [mediaDataUrls]="mediaDataUrls()"
19249
21470
  [zIndex]="i"
21471
+ [obstacles]="connectorObstacles()"
21472
+ [canvasWidth]="canvasSize().width"
21473
+ [canvasHeight]="canvasSize().height"
19250
21474
  />
19251
21475
  }
19252
21476
  @for (box of selectionBoxes(); track box.id) {
@@ -26030,5 +28254,5 @@ function cn(...values) {
26030
28254
  * Generated bundle index. Do not edit.
26031
28255
  */
26032
28256
 
26033
- export { AccessibilityPanelComponent, AccessibilityService, AnimationPanelComponent, AnimationPlaybackService, BroadcastDialogComponent, CURSOR_PALETTE, ChartRendererComponent, CollaborationCursorsComponent, CollaborationService, CommentsPanelComponent, CommentsService, ConnectorRendererComponent, DEFAULT_BROADCAST_SERVER_URL, DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, DEFAULT_FILL_COLOR, DEFAULT_PRINT_SETTINGS, DEFAULT_SLIDE_BACKGROUND, DEFAULT_STROKE_COLOR, DEFAULT_TEXT_COLOR$1 as DEFAULT_TEXT_COLOR, EMBEDDED_FONTS_STYLE_ID, EditorContextMenuComponent, EditorHistory, EditorStateService, EditorToolbarComponent, ElementRendererComponent, EmbeddedFontsService, EquationRendererComponent, ExportService, FindBarComponent, HANDOUT_OPTIONS, HyperlinkDialogComponent, InkRendererComponent, InspectorPanelComponent, LoadContentService, ModalDialogComponent, Model3DRendererComponent, OleRendererComponent, PowerPointViewerComponent, PresentationOverlayComponent, PresentationTransitionOverlayComponent, PresenterViewComponent, PrintDialogComponent, PrintService, PrintSettingsPanelComponent, PropertiesDialogComponent, RESIZE_HANDLES, SEVERITY_GROUPS, SEVERITY_LABELS, SLIDE_TRANSITION_KEYFRAMES, ShareDialogComponent, SignaturesPanelComponent, SignaturesService, SlideCanvasComponent, SlideSorterOverlayComponent, SlidesPanelComponent, SmartArtRendererComponent, TYPE_LABELS$1 as TYPE_LABELS, TableRendererComponent, VIEWER_THEME, ZoomRendererComponent, addCommentToList, advanceStep, applyMove, applyResize, assignUserColor, bringForward, bringToFront, buildBroadcastConfig, buildBroadcastViewerUrl, buildClearHyperlinkPatch, buildClickGroups, buildCollaborationConfig, buildCssGradientFromShapeStyle, buildEmbeddedFontStyles, buildFontFaceRule, buildHyperlinkPatch, buildPatternFillCss, buildPrintDocument, buildPropertiesPatch, buildShareUrl, bulletIndentPx, canStartBroadcast, canStartShare, canUseClipboard, clampCursorPosition, clampNotesFontSize, clampStep, cn, collectAccessibilityIssues, collectElementText, collectSlideText, computeHandoutLayout, computePageCount, computeSlideIndices, computeTimerProgress, convertOmmlToMathMl, countAccessibilityIssues, defaultCssVars, defaultRadius, defaultThemeColors, deleteElementsByIds, derivePresenceList, duplicateElementById, durationOf, estimatePageCount, fontMimeForFormat, formatAutoNumber, formatCursorLabel, formatElapsed, formatPropertyDate, formatTime, generateBroadcastRoomId, generateCommentId, getContainerStyle, getImageSrc, getPatternSvg, getResolvedShapeClipPath, getResolvedShapeClipPathFor, getShapeFillStrokeStyle, getSlideBackgroundStyle, getSlideTransitionAnimations, getTextBlockStyle, groupIssuesBySeverity, hasExistingLink, headerLabel, isInjectableUrl, isPpactionUrl, isSigned, isUrlSafe, isValidRoomId, issueTrackKey, issueTypeLabel, moveElementBy, normalizeFontFormat, normalizeSlidesPerPage, ommlToMathml, overallStatus, pendingElementStyles, presenceToCursors, provideViewerTheme, removeCommentFromList, renderToCanvas, resizeElement, resolveFontVariant, resolveHyperlinkHref, resolveParagraphBullet, resolvePresenterNotes, resolveTransitionDuration, revealedElementStyles, sanitizeColor, sanitizeSlideIndex, sanitizeUserName, searchSlides, seedBroadcastFields, seedHyperlinkDraft, seedPropertiesDraft, seedShareFields, sendBackward, sendToBack, setElementPosition, signatureCountLabel, signatureKey, signatureTimestamp, signerName, statusKind, statusLabel, themeStyle, themeToCssVars, toggleCommentResolvedInList, updateElementById, validatePrintSettings, validateRoomId, worstStatus };
28257
+ export { AccessibilityPanelComponent, AccessibilityService, AnimationPanelComponent, AnimationPlaybackService, BroadcastDialogComponent, CURSOR_PALETTE, ChartRendererComponent, CollaborationCursorsComponent, CollaborationService, CommentsPanelComponent, CommentsService, ConnectorRendererComponent, ConnectorTextOverlayComponent, DEFAULT_BROADCAST_SERVER_URL, DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, DEFAULT_FILL_COLOR, DEFAULT_PRINT_SETTINGS, DEFAULT_SLIDE_BACKGROUND, DEFAULT_STROKE_COLOR, DEFAULT_TEXT_COLOR$1 as DEFAULT_TEXT_COLOR, EMBEDDED_FONTS_STYLE_ID, EditorContextMenuComponent, EditorHistory, EditorStateService, EditorToolbarComponent, ElementRendererComponent, EmbeddedFontsService, EquationRendererComponent, ExportService, FindBarComponent, HANDOUT_OPTIONS, HyperlinkDialogComponent, InkRendererComponent, InspectorPanelComponent, LoadContentService, ModalDialogComponent, Model3DRendererComponent, OleRendererComponent, PowerPointViewerComponent, PresentationOverlayComponent, PresentationTransitionOverlayComponent, PresenterViewComponent, PrintDialogComponent, PrintService, PrintSettingsPanelComponent, PropertiesDialogComponent, RESIZE_HANDLES, SEVERITY_GROUPS, SEVERITY_LABELS, SLIDE_TRANSITION_KEYFRAMES, ShareDialogComponent, SignaturesPanelComponent, SignaturesService, SlideCanvasComponent, SlideSorterOverlayComponent, SlidesPanelComponent, SmartArtRendererComponent, TYPE_LABELS$1 as TYPE_LABELS, TableRendererComponent, VIEWER_THEME, ZoomRendererComponent, addCommentToList, advanceStep, applyMove, applyResize, assignUserColor, bringForward, bringToFront, buildBroadcastConfig, buildBroadcastViewerUrl, buildClearHyperlinkPatch, buildClickGroups, buildCollaborationConfig, buildCssGradientFromShapeStyle, buildEmbeddedFontStyles, buildFontFaceRule, buildHyperlinkPatch, buildPatternFillCss, buildPrintDocument, buildPropertiesPatch, buildShareUrl, bulletIndentPx, canStartBroadcast, canStartShare, canUseClipboard, clampCursorPosition, clampNotesFontSize, clampStep, cn, collectAccessibilityIssues, collectElementText, collectSlideText, computeHandoutLayout, computePageCount, computeSlideIndices, computeTimerProgress, convertOmmlToMathMl, countAccessibilityIssues, defaultCssVars, defaultRadius, defaultThemeColors, deleteElementsByIds, derivePresenceList, duplicateElementById, durationOf, estimatePageCount, fontMimeForFormat, formatAutoNumber, formatCursorLabel, formatElapsed, formatPropertyDate, formatTime, generateBroadcastRoomId, generateCommentId, getContainerStyle, getImageSrc, getPatternSvg, getResolvedShapeClipPath, getResolvedShapeClipPathFor, getShapeFillStrokeStyle, getSlideBackgroundStyle, getSlideTransitionAnimations, getTextBlockStyle, groupIssuesBySeverity, hasExistingLink, headerLabel, isInjectableUrl, isPpactionUrl, isSigned, isUrlSafe, isValidRoomId, issueTrackKey, issueTypeLabel, moveElementBy, normalizeFontFormat, normalizeSlidesPerPage, ommlToMathml, overallStatus, pendingElementStyles, presenceToCursors, provideViewerTheme, removeCommentFromList, renderToCanvas, resizeElement, resolveFontVariant, resolveHyperlinkHref, resolveParagraphBullet, resolvePresenterNotes, resolveTransitionDuration, revealedElementStyles, routeOrthogonalConnector, sanitizeColor, sanitizeSlideIndex, sanitizeUserName, searchSlides, seedBroadcastFields, seedHyperlinkDraft, seedPropertiesDraft, seedShareFields, sendBackward, sendToBack, setElementPosition, signatureCountLabel, signatureKey, signatureTimestamp, signerName, statusKind, statusLabel, themeStyle, themeToCssVars, toggleCommentResolvedInList, updateElementById, validatePrintSettings, validateRoomId, waypointsToPathD, worstStatus };
26034
28258
  //# sourceMappingURL=pptx-angular-viewer.mjs.map