@peter.prib/renderengine 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/renderengine-workspace.umd.js +525 -229
- package/dist/renderengine.umd.js +390 -107
- package/documentation/REFERENCE.md +347 -25
- package/package.json +1 -1
- package/src/charting/ArchimateDiagramEditor.js +2 -1
- package/src/charting/Edge.js +22 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +21 -5
- package/src/charting/archimate/ViewGroupTemplate.js +5 -2
- package/src/charting/diagramModel/DiagramJsonFileFormat.js +32 -0
- package/src/charting/diagramModel/DiagramModel.js +39 -0
- package/src/charting/diagramModel/DiagramView.js +97 -61
- package/src/charting/diagramModel/index.js +11 -10
- package/src/charting/diagrams/AddGroupToViewCommand.js +35 -0
- package/src/charting/diagrams/ArchimateModel.js +53 -13
- package/src/charting/diagrams/NestInstanceCommand.js +2 -1
- package/src/charting/templates/templateRegistry.js +70 -0
- package/src/index.js +4 -3
- package/src/workspace/parts/ArchimateEditorPart.js +2 -2
- package/src/workspace/parts/DiagramEditorPart.js +144 -131
|
@@ -104,6 +104,31 @@ catalog `key` for you.
|
|
|
104
104
|
| `wordCloud` | `wordCloud(data, opts)` | `Array<{text: string, value: number}>` | spiral placement + bounding-box collision; `minFontSize`, `maxFontSize`, `padding` |
|
|
105
105
|
| `parallelCoordinates` | `parallelCoordinates(data, opts)` | `Array<{label?, category?, [dim: number]}>` | one vertical axis per numeric dimension; category-driven colors; `dimensions` |
|
|
106
106
|
| `geoMap` | `geoMap(data, opts)` | `{features: Array<{id, name, value?, color?, geometry}>, bubbles?: Array<{lat, lon, value}>}` | equirectangular projection; `center`, `scale`, `colorScale`; see note below |
|
|
107
|
+
| `polarArea` | `polarAreaChart(data, opts)` | `Array<{label, value, exploded?}>` | Nightingale rose equal-angle wedges; `scale: 'radius'|'area'`, `extrude3D`, `depth`, `innerRadiusRatio` |
|
|
108
|
+
| `pie3D` | `pieChart3D(data, opts)` | `Array<{label, value}>` | isometric 3D pie; `depth`, `tilt`, `exploded`, `colors` |
|
|
109
|
+
| `donut3D` | `donutChart3D(data, opts)` | `Array<{label, value}>` | isometric 3D donut; `innerRadiusRatio`, `depth`, `tilt` |
|
|
110
|
+
| `pareto` | `paretoChart(data, opts)` | `Array<{label, value}>` | sorted descending bars with cumulative percentage line (80/20 rule); `barColor`, `lineColor` |
|
|
111
|
+
| `lollipop` | `lollipopChart(data, opts)` | `Array<{label, value}>` | stem with circular point markers; `orientation: 'vertical'|'horizontal'`, `pointRadius` |
|
|
112
|
+
| `marimekko` | `marimekkoChart(data, opts)` | `Array<{name, values: Array<{label, value}>}>` | 2D mosaic chart where both bar width and height encode values |
|
|
113
|
+
| `icicle` | `icicleChart(data, opts)` | `Array<{label, value?, children?}>` | hierarchical partition chart laying out tree levels as rectangular blocks; `orientation` |
|
|
114
|
+
| `dumbbell` | `dumbbellChart(data, opts)` | `Array<{label, start, end}>` or multi-series `Array<{name, values}>` | connected pair of dots showing change across categories; `colorByDelta`, `showDeltas`, `orientation` |
|
|
115
|
+
| `slopegraph` | `slopegraph(data, opts)` | `Array<{label, values: number[]}>` or `Array<{label, start, end}>` | connects categories across stages with direct labels; `stageLabels`, `collisionAvoidance` |
|
|
116
|
+
| `circlePack` | `circlePackChart(data, opts)` | `Array<{label, value?, children?}>` | nested tangent circles sized by value with translucent branch containment; `padding` |
|
|
117
|
+
| `ridgeline` | `ridgelineChart(data, opts)` | `Array<{label, values: number[]}>` | vertically staggered, overlapping Gaussian-KDE density curves (Joy plot); `overlap`, `showMedian` |
|
|
118
|
+
| `beeswarm` | `beeswarmChart(data, opts)` | `Array<{label, values: number[]}>` | packed individual sample points along category slots; `mode: 'beeswarm'|'raincloud'`, `radius` |
|
|
119
|
+
| `combo` | `comboChart(data, opts)` | multi-series with type specifications | composite multi-series chart combining bars, lines, and area layers on primary/secondary axes |
|
|
120
|
+
| `hexbin` | `hexbinChart(data, opts)` | `Array<{x, y, value?}>` or `Array<[x, y]>` | 2D hexagonal density binning for large scatter clouds; `radius`, `minCount`, `colorScale` |
|
|
121
|
+
| `ecdf` | `ecdfChart(data, opts)` | `number[]` or `Array<{label, values: number[]}>` | empirical cumulative distribution step function (0..1); `showMedian`, `showQuantiles`, `step` |
|
|
122
|
+
| `splom` | `scatterMatrixChart(data, opts)` | `Array<object>` with numeric dimensions | scatter plot matrix of pairwise bivariate relationships; `dimensions`, `colorCol` |
|
|
123
|
+
| `sparklineTable` | `sparklineTableChart(data, opts)` | `Array<{label, values: number[], current?, change?}>` | data table with embedded inline trendline sparklines; `columns` |
|
|
124
|
+
| `horizon` | `horizonChart(data, opts)` | `Array<{label, values: number[]}>` | compact layered area strips folding positive and negative magnitude bands; `bands`, `posColors` |
|
|
125
|
+
| `ohlc` | `ohlcChart(data, opts)` | `Array<{label, open, high, low, close}>` | Open-High-Low-Close financial bar chart; `upColor`, `downColor` |
|
|
126
|
+
| `alluvial` | `alluvialDiagram(data, opts)` | `{dimensions: string[], rows: Array<object>}` or `Array<{steps, value}>` | multi-stage categorical flow diagram across ordered dimensions; `nodeWidth`, `curvature` |
|
|
127
|
+
| `renko` | `renkoChart(data, opts)` | `number[]` or `Array<{label, close}>` | brick-based trend-filtering chart filtering out time noise; `brickSize` |
|
|
128
|
+
| `contour` | `contourChart(data, opts)` | `number[][]` (2D scalar grid) or `Array<{x, y, value}>` | marching-squares isolines and filled isoband contours; `levels`, `filled`, `showLines` |
|
|
129
|
+
| `kagi` | `kagiChart(data, opts)` | `number[]` or `Array<{label, close}>` | trend reversal chart with alternating line thickness; `reversalAmount` |
|
|
130
|
+
| `pointAndFigure` | `pointAndFigureChart(data, opts)` | `number[]` or `Array<{label, close}>` | columns of X's (rising) and O's (falling); `boxSize`, `reversal` |
|
|
131
|
+
| `surface3D` | `surfaceChart3D(data, opts)` | `Array<{x, z, value}>` or `{xLabels, zLabels, values}` | continuous 3D surface mesh with gradient height coloring, wireframe, and orbit camera |
|
|
107
132
|
|
|
108
133
|
**Gauges.** Every gauge is a `GaugeChart` (`src/charting/charts/GaugeChart.js`) and takes the same options:
|
|
109
134
|
|
|
@@ -1340,6 +1365,278 @@ SVG each mark answers a click with `{ label, series }`.
|
|
|
1340
1365
|
engine.barChart(data, { onDatumClick: ({ label }) => openDetail(label) });
|
|
1341
1366
|
```
|
|
1342
1367
|
|
|
1368
|
+
## Advanced Statistical, Flow, Distribution & 3D Charts
|
|
1369
|
+
|
|
1370
|
+
Specialized charts designed for dense distributions, multi-stage transitions, continuous terrains, and flow modeling.
|
|
1371
|
+
|
|
1372
|
+
### Dumbbell / Gap Chart — `dumbbellChart(data, options)`
|
|
1373
|
+
|
|
1374
|
+
A connected pair of dots per category showing before/after, actual/target, or interval comparisons without the visual clutter of clustered bars.
|
|
1375
|
+
|
|
1376
|
+
- **Data shapes:**
|
|
1377
|
+
- `Array<{label: string, start: number, end: number}>`
|
|
1378
|
+
- Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>` (first two series used as start and end)
|
|
1379
|
+
- **Notable options:**
|
|
1380
|
+
- `orientation`: `'horizontal'` (default) or `'vertical'`.
|
|
1381
|
+
- `colorByDelta`: `true` to color connectors and endpoints by direction of change (green for gain, red for loss).
|
|
1382
|
+
- `startColor`, `endColor`: Custom endpoint colors when `colorByDelta` is false.
|
|
1383
|
+
- `upColor`, `downColor`, `neutralColor`: Colors used when `colorByDelta: true`.
|
|
1384
|
+
- `showDeltas`: `true` to render direct delta badges (e.g. `+12%`, `-5`) next to the endpoints.
|
|
1385
|
+
- `showValues`: `true` to render numeric value labels at dots.
|
|
1386
|
+
- `pointRadius`: Radius of endpoint circles in pixels (default `5`).
|
|
1387
|
+
- `startLabel`, `endLabel`: Custom legend labels for the start and end series.
|
|
1388
|
+
|
|
1389
|
+
```javascript
|
|
1390
|
+
engine.dumbbellChart([
|
|
1391
|
+
{ label: 'Latency', start: 240, end: 110 },
|
|
1392
|
+
{ label: 'Throughput', start: 850, end: 1420 },
|
|
1393
|
+
{ label: 'Error Rate', start: 4.2, end: 0.8 },
|
|
1394
|
+
], { colorByDelta: true, showDeltas: true, showValues: true });
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
### Slopegraph — `slopegraph(data, options)`
|
|
1398
|
+
|
|
1399
|
+
Connects categories across two or more discrete stages or time points to highlight hierarchy shifts, rate-of-change differences, and rank reversals.
|
|
1400
|
+
|
|
1401
|
+
- **Data shapes:**
|
|
1402
|
+
- `Array<{label: string, values: number[]}>` (multi-stage trajectory)
|
|
1403
|
+
- `Array<{label: string, start: number, end: number}>` (2-stage before/after)
|
|
1404
|
+
- **Notable options:**
|
|
1405
|
+
- `stageLabels`: Array of column headings for the stages (e.g. `['2024', '2025', '2026']`).
|
|
1406
|
+
- `showDirectLabels`: `true` (default) to label line endpoints directly, avoiding an external legend.
|
|
1407
|
+
- `labelSide`: `'both'` (default), `'left'`, or `'right'`.
|
|
1408
|
+
- `collisionAvoidance`: `true` (default) to run iterative vertical relaxation preventing overlapping text labels on crowded axes.
|
|
1409
|
+
- `lineWidth`: Stroke width of trajectory lines (default `2`).
|
|
1410
|
+
- `pointRadius`: Radius of dots at each stage (default `4`).
|
|
1411
|
+
- `highlightOnHover`: `true` (default) to dim competing lines on cursor hover.
|
|
1412
|
+
|
|
1413
|
+
```javascript
|
|
1414
|
+
engine.slopegraph([
|
|
1415
|
+
{ label: 'Product Alpha', values: [12, 19, 28] },
|
|
1416
|
+
{ label: 'Product Beta', values: [24, 21, 15] },
|
|
1417
|
+
{ label: 'Product Gamma', values: [8, 14, 22] },
|
|
1418
|
+
], { stageLabels: ['Q1', 'Q2', 'Q3'], showDirectLabels: true });
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
### Ridgeline Plot (Joy Plot) — `ridgelineChart(data, options)`
|
|
1422
|
+
|
|
1423
|
+
Vertically staggered, partially overlapping Gaussian kernel density estimates (KDE) across multiple ordered or categorical groups. Ideal for comparing distributions over time or stages in compact vertical space.
|
|
1424
|
+
|
|
1425
|
+
- **Data shape:** `Array<{label: string, values: number[]}>` (raw sample observations per group).
|
|
1426
|
+
- **Notable options:**
|
|
1427
|
+
- `overlap`: Vertical overlap ratio between adjacent ridges (default `0.65`; `0` = non-overlapping rows, `1.0` = high overlap).
|
|
1428
|
+
- `fillOpacity`: Opacity of the filled ridge area under each density curve (default `0.74`).
|
|
1429
|
+
- `showMedian`: `true` (default) to draw vertical line ticks at each group's sample median.
|
|
1430
|
+
- `medianColor`: Color of the median tick indicator (default `'#ffffff'`).
|
|
1431
|
+
- `colors`: Palette array for ridges, or auto-assigned from workspace theme.
|
|
1432
|
+
|
|
1433
|
+
```javascript
|
|
1434
|
+
engine.ridgelineChart([
|
|
1435
|
+
{ label: 'Sprint 1', values: [14, 18, 22, 25, 27, 28, 30, 35, 42] },
|
|
1436
|
+
{ label: 'Sprint 2', values: [10, 12, 15, 18, 20, 22, 24, 26, 29] },
|
|
1437
|
+
{ label: 'Sprint 3', values: [8, 9, 11, 14, 16, 17, 18, 20, 22] },
|
|
1438
|
+
], { overlap: 0.7, showMedian: true });
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
### Beeswarm & Raincloud Plot — `beeswarmChart(data, options)`
|
|
1442
|
+
|
|
1443
|
+
Plots every individual observation as a non-overlapping circle packed tightly along a category axis without jitter noise. Raincloud mode pairs the packed dot cloud with a half-KDE density violin and a compact box plot.
|
|
1444
|
+
|
|
1445
|
+
- **Data shape:** `Array<{label: string, values: number[]}>` (raw sample numbers per group).
|
|
1446
|
+
- **Notable options:**
|
|
1447
|
+
- `mode`: `'beeswarm'` (default; centered packed point swarm) or `'raincloud'` (half-violin cloud + box plot + offset swarm).
|
|
1448
|
+
- `radius`: Radius of each observation dot (default `3.5`).
|
|
1449
|
+
- `padding`: Minimum pixel gap between adjacent packed points (default `1`).
|
|
1450
|
+
- `halfViolin`: In raincloud mode, draws upper density envelope (default `true`).
|
|
1451
|
+
- `boxPlot`: In raincloud mode, draws embedded quartiles and median bar (default `true`).
|
|
1452
|
+
- `colors`: Array of categorical colors per group.
|
|
1453
|
+
|
|
1454
|
+
```javascript
|
|
1455
|
+
engine.beeswarmChart([
|
|
1456
|
+
{ label: 'Control', values: [2.1, 2.4, 2.5, 2.7, 2.9, 3.1, 3.2, 3.6, 4.0] },
|
|
1457
|
+
{ label: 'Treatment', values: [3.2, 3.5, 3.8, 4.1, 4.2, 4.5, 4.8, 5.2, 5.9] },
|
|
1458
|
+
], { mode: 'raincloud', radius: 4 });
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
### Empirical Cumulative Distribution Function — `ecdfChart(data, options)`
|
|
1462
|
+
|
|
1463
|
+
Draws exact empirical step CDFs \(P(X \le x)\) from 0 to 1, comparing distributions without bin-width artifacts. Includes automated Kolmogorov-Smirnov distance calculations.
|
|
1464
|
+
|
|
1465
|
+
- **Data shapes:**
|
|
1466
|
+
- `number[]` (single sample)
|
|
1467
|
+
- `Array<{label: string, values: number[]}>` (multi-group comparison)
|
|
1468
|
+
- **Notable options:**
|
|
1469
|
+
- `showMedian`: `true` (default) to project dashed 50th-percentile lines to axes.
|
|
1470
|
+
- `showQuantiles`: `true` to annotate 25th, 50th, and 75th percentile markers.
|
|
1471
|
+
- `step`: Step interpolation style (`'vh'` default: vertical-then-horizontal, or `'hv'`).
|
|
1472
|
+
- `compare`: When two series are supplied, calculates and highlights the maximum vertical distance \(D_{KS}\) (`EcdfChart.ksDistance(s1, s2)`).
|
|
1473
|
+
- `colors`: Palette array for series steps.
|
|
1474
|
+
|
|
1475
|
+
```javascript
|
|
1476
|
+
engine.ecdfChart([
|
|
1477
|
+
{ label: 'Algorithm A', values: [12, 15, 18, 22, 25, 29, 34, 40] },
|
|
1478
|
+
{ label: 'Algorithm B', values: [8, 11, 14, 16, 19, 21, 24, 28] },
|
|
1479
|
+
], { showMedian: true, compare: true });
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
### Hexagonal 2D Density Binning — `hexbinChart(data, options)`
|
|
1483
|
+
|
|
1484
|
+
Aggregates dense 2D scatter coordinate clouds into regular hexagonal tiles, preventing visual saturation and overplotting in large point datasets.
|
|
1485
|
+
|
|
1486
|
+
- **Data shapes:**
|
|
1487
|
+
- `Array<{x: number, y: number, value?: number}>`
|
|
1488
|
+
- `Array<[x: number, y: number]>`
|
|
1489
|
+
- **Notable options:**
|
|
1490
|
+
- `radius`: Outer radius of each hexagon cell in pixels (default `14`).
|
|
1491
|
+
- `minCount`: Minimum point count threshold required to render a cell (default `1`).
|
|
1492
|
+
- `colorScale`: Sequential color palette array (e.g. `['#e0f3f8', '#67a9cf', '#02818a', '#bd0026']`).
|
|
1493
|
+
- `title`, `xAxisLabel`, `yAxisLabel`: Chart and axis labels.
|
|
1494
|
+
|
|
1495
|
+
```javascript
|
|
1496
|
+
const points = Array.from({ length: 5000 }, () => ({
|
|
1497
|
+
x: Math.random() * 100,
|
|
1498
|
+
y: Math.random() * 100,
|
|
1499
|
+
}));
|
|
1500
|
+
engine.hexbinChart(points, { radius: 12, minCount: 2 });
|
|
1501
|
+
```
|
|
1502
|
+
|
|
1503
|
+
### Horizon Chart — `horizonChart(data, options)`
|
|
1504
|
+
|
|
1505
|
+
Folds continuous time-series magnitude into 2 to 5 layered colored bands within a narrow vertical band (e.g. 30–50px), preserving high precision while displaying dozens of series simultaneously.
|
|
1506
|
+
|
|
1507
|
+
- **Data shape:** `Array<{label: string, values: number[]}>`
|
|
1508
|
+
- **Notable options:**
|
|
1509
|
+
- `bands`: Number of layered folding bands (default `3`).
|
|
1510
|
+
- `posColors`: Color gradient array for positive values (default 3-stage blues).
|
|
1511
|
+
- `negColors`: Color gradient array for negative values (default 3-stage reds).
|
|
1512
|
+
- `title`: Chart title.
|
|
1513
|
+
|
|
1514
|
+
```javascript
|
|
1515
|
+
engine.horizonChart([
|
|
1516
|
+
{ label: 'CPU Core 0', values: [10, 25, 45, 80, 95, 60, 40, 30] },
|
|
1517
|
+
{ label: 'CPU Core 1', values: [-15, 20, 50, 70, 85, 45, 20, -10] },
|
|
1518
|
+
], { bands: 3 });
|
|
1519
|
+
```
|
|
1520
|
+
|
|
1521
|
+
### Circle Packing — `circlePackChart(data, options)`
|
|
1522
|
+
|
|
1523
|
+
Represents hierarchical tree structures as nested tangent circles sized by value, with translucent ancestor boundary rings and hover elevation.
|
|
1524
|
+
|
|
1525
|
+
- **Data shape:** Hierarchical tree `Array<{label: string, value?: number, children?: Array}>`
|
|
1526
|
+
- **Notable options:**
|
|
1527
|
+
- `padding`: Pixel gap between tangent sibling circles (default `2`).
|
|
1528
|
+
- `showLabels`: `true` (default) to label circles that have sufficient interior area.
|
|
1529
|
+
- `minLabelRadius`: Minimum circle radius in pixels required to render text inside (default `14`).
|
|
1530
|
+
- `colors`: Palette array mapped across top-level tree branches.
|
|
1531
|
+
|
|
1532
|
+
```javascript
|
|
1533
|
+
engine.circlePackChart([{
|
|
1534
|
+
label: 'Architecture',
|
|
1535
|
+
children: [
|
|
1536
|
+
{ label: 'Backend', children: [{ label: 'API', value: 40 }, { label: 'DB', value: 30 }] },
|
|
1537
|
+
{ label: 'Frontend', children: [{ label: 'Web', value: 25 }, { label: 'Mobile', value: 15 }] },
|
|
1538
|
+
],
|
|
1539
|
+
}], { padding: 4 });
|
|
1540
|
+
```
|
|
1541
|
+
|
|
1542
|
+
### Alluvial Diagram — `alluvialDiagram(data, options)`
|
|
1543
|
+
|
|
1544
|
+
Visualizes categorical flows across three or more sequential dimensions using smooth cubic bezier ribbons whose widths represent flow volumes.
|
|
1545
|
+
|
|
1546
|
+
- **Data shapes:**
|
|
1547
|
+
- Multi-stage pathway list: `Array<{steps: string[], value: number}>`
|
|
1548
|
+
- Tabular rows: `{dimensions: string[], rows: Array<object>}`
|
|
1549
|
+
- Edge list: `Array<{source: string, target: string, value: number}>`
|
|
1550
|
+
- **Notable options:**
|
|
1551
|
+
- `dimensions`: Array of dimension stage names (e.g. `['Role', 'Department', 'Access Level']`).
|
|
1552
|
+
- `nodeWidth`: Width of category pillar rectangles in pixels (default `18`).
|
|
1553
|
+
- `nodePadding`: Vertical separation between category nodes (default `12`).
|
|
1554
|
+
- `curvature`: Bezier curvature ratio for flow links (default `0.5`).
|
|
1555
|
+
- `linkOpacity`: Fill opacity for flow ribbons (default `0.55`).
|
|
1556
|
+
- `colors`: Palette array for node categories.
|
|
1557
|
+
|
|
1558
|
+
```javascript
|
|
1559
|
+
engine.alluvialDiagram([
|
|
1560
|
+
{ steps: ['Engineering', 'Senior', 'Admin'], value: 12 },
|
|
1561
|
+
{ steps: ['Engineering', 'Junior', 'User'], value: 28 },
|
|
1562
|
+
{ steps: ['Product', 'Lead', 'Admin'], value: 5 },
|
|
1563
|
+
{ steps: ['Product', 'Analyst', 'User'], value: 15 },
|
|
1564
|
+
], { dimensions: ['Department', 'Seniority', 'Permission'] });
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
### Polar Area (Nightingale Rose) — `polarAreaChart(data, options)`
|
|
1568
|
+
|
|
1569
|
+
Divides a circle into equal angular sectors where the radial distance (or area) of each sector represents its magnitude. Supports optional 3D isometric extrusion and interactive wedge pull-out.
|
|
1570
|
+
|
|
1571
|
+
- **Data shape:** `Array<{label: string, value: number, exploded?: boolean}>`
|
|
1572
|
+
- **Notable options:**
|
|
1573
|
+
- `scale`: `'radius'` (linear radial distance) or `'area'` (square-root scaling so slice area is proportional to value).
|
|
1574
|
+
- `extrude3D`: `true` to render as an isometric 3D cylindrical cylinder with depth shading.
|
|
1575
|
+
- `depth`: Extrusion height in pixels when `extrude3D: true` (default `22`).
|
|
1576
|
+
- `innerRadiusRatio`: Donut hole ratio (default `0.12`).
|
|
1577
|
+
- `padAngle`: Angular spacing between sectors in radians (default `0.02`).
|
|
1578
|
+
- `hoverExplode`: `true` to pull out wedges under the cursor.
|
|
1579
|
+
|
|
1580
|
+
```javascript
|
|
1581
|
+
engine.polarAreaChart([
|
|
1582
|
+
{ label: 'North', value: 45 },
|
|
1583
|
+
{ label: 'East', value: 80 },
|
|
1584
|
+
{ label: 'South', value: 35 },
|
|
1585
|
+
{ label: 'West', value: 60 },
|
|
1586
|
+
], { scale: 'area', extrude3D: true, depth: 25 });
|
|
1587
|
+
```
|
|
1588
|
+
|
|
1589
|
+
### 3D Surface Mesh — `surfaceChart3D(data, options)`
|
|
1590
|
+
|
|
1591
|
+
Renders continuous 3D mathematical surfaces, terrain elevations, or 2D matrix loss landscapes using true 4×4 perspective transformations and interactive orbit camera rotation.
|
|
1592
|
+
|
|
1593
|
+
- **Data shapes:**
|
|
1594
|
+
- Regular grid: `{xLabels: string[]|number[], zLabels: string[]|number[], values: number[][]}`
|
|
1595
|
+
- Coordinate tuples: `Array<{x: string|number, z: string|number, value: number}>`
|
|
1596
|
+
- **Notable options:**
|
|
1597
|
+
- `wireframe`: `true` (default) to overlay contrasting grid lines on surface polygons.
|
|
1598
|
+
- `wireframeColor`: Stroke color for wireframe lines (default `'rgba(255,255,255,0.45)'`).
|
|
1599
|
+
- `colorScale`: Array of gradient color stops for height-based surface shading.
|
|
1600
|
+
- `initialTheta`, `initialPhi`, `initialDistance`: Initial camera angles and zoom distance.
|
|
1601
|
+
- Orbit interaction: Drag to rotate camera azimuth and elevation, mouse wheel to zoom in/out.
|
|
1602
|
+
|
|
1603
|
+
```javascript
|
|
1604
|
+
const xLabels = [-2, -1, 0, 1, 2];
|
|
1605
|
+
const zLabels = [-2, -1, 0, 1, 2];
|
|
1606
|
+
const values = xLabels.map(x => zLabels.map(z => Math.sin(Math.sqrt(x * x + z * z))));
|
|
1607
|
+
|
|
1608
|
+
engine.surfaceChart3D({ xLabels, zLabels, values }, {
|
|
1609
|
+
title: 'Bivariate Ripple Surface',
|
|
1610
|
+
wireframe: true,
|
|
1611
|
+
});
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
### Contour Plot — `contourChart(data, options)`
|
|
1615
|
+
|
|
1616
|
+
Generates smooth isoline curves and filled isoband color regions from 2D scalar fields using the marching-squares algorithm with automated numeric elevation labels.
|
|
1617
|
+
|
|
1618
|
+
- **Data shapes:**
|
|
1619
|
+
- 2D scalar matrix: `number[][]` (rows × columns)
|
|
1620
|
+
- Coordinate list: `Array<{x: number, y: number, value: number}>`
|
|
1621
|
+
- **Notable options:**
|
|
1622
|
+
- `levels`: Number of contour threshold intervals (default `8`), or an explicit array of numbers `[0.1, 0.25, 0.5, 0.75]`.
|
|
1623
|
+
- `filled`: `true` (default) to render shaded polygon bands between isolines.
|
|
1624
|
+
- `showLines`: `true` (default) to stroke isoline boundaries.
|
|
1625
|
+
- `showLabels`: `true` (default) to annotate contour lines with numeric elevation values.
|
|
1626
|
+
- `colorScale`: Array of colors interpolated across contour levels.
|
|
1627
|
+
- `lineColor`: Stroke color for isolines (default `'rgba(0,0,0,0.3)'`).
|
|
1628
|
+
|
|
1629
|
+
```javascript
|
|
1630
|
+
const grid = [
|
|
1631
|
+
[0.1, 0.2, 0.4, 0.2, 0.1],
|
|
1632
|
+
[0.2, 0.6, 0.9, 0.6, 0.2],
|
|
1633
|
+
[0.4, 0.9, 1.0, 0.9, 0.4],
|
|
1634
|
+
[0.2, 0.6, 0.9, 0.6, 0.2],
|
|
1635
|
+
[0.1, 0.2, 0.4, 0.2, 0.1],
|
|
1636
|
+
];
|
|
1637
|
+
engine.contourChart(grid, { levels: 6, filled: true, showLabels: true });
|
|
1638
|
+
```
|
|
1639
|
+
|
|
1343
1640
|
## Common options
|
|
1344
1641
|
|
|
1345
1642
|
Not every method takes every option below — the table above calls out what's notable per type —
|
|
@@ -1523,10 +1820,10 @@ data — `addDataPoint` is a **no-op** when it's `'none'`:
|
|
|
1523
1820
|
|
|
1524
1821
|
| `streamShape` | Chart types | Behavior |
|
|
1525
1822
|
|---|---|---|
|
|
1526
|
-
| `flat` | `bar`, `pie`, `scatter`, `bubble`, `candlestick`, `stackedBar`, `funnel`, `waterfall`, `histogram`, `qq`, `lag`, `treemap`, `sunburst`, `outline`, `bar3D`, `bubble3D` | one new record appended to the outer array |
|
|
1527
|
-
| `series` | `line`, `area`, `pipe` | every series' own `values` grows by one point |
|
|
1528
|
-
| `grouped-sample` | `boxPlot`, `density`, `violin` | exactly ONE randomly-chosen group's `values` grows (a new sample observation belongs to one group, unlike a shared time axis) |
|
|
1529
|
-
| `none` | `heatmap`, `radar` (fixed matrix/axis-set), `bar3DOrbit`/`stackedBar3DOrbit` (fixed grid), `gauge-dial`/`gauge-bar`/`bullet` (a value to replace, not a window) | `addDataPoint` no-ops — for gauges, just call `dialGauge(newValue)`/`barGauge(newValue)`/`bulletGauge(newValue)` again directly |
|
|
1823
|
+
| `flat` | `bar`, `pie`, `scatter`, `bubble`, `candlestick`, `stackedBar`, `funnel`, `waterfall`, `histogram`, `qq`, `lag`, `treemap`, `sunburst`, `outline`, `bar3D`, `bubble3D`, `dumbbell`, `slopegraph`, `circlePack`, `horizon`, `polarArea`, `hexbin`, `pareto`, `lollipop` | one new record appended to the outer array |
|
|
1824
|
+
| `series` | `line`, `area`, `pipe`, `combo` | every series' own `values` grows by one point |
|
|
1825
|
+
| `grouped-sample` | `boxPlot`, `density`, `violin`, `ecdf`, `beeswarm`, `ridgeline` | exactly ONE randomly-chosen group's `values` grows (a new sample observation belongs to one group, unlike a shared time axis) |
|
|
1826
|
+
| `none` | `heatmap`, `radar` (fixed matrix/axis-set), `bar3DOrbit`/`stackedBar3DOrbit`/`surface3D` (fixed grid), `gauge-dial`/`gauge-bar`/`bullet` (a value to replace, not a window), `alluvial`, `contour`, `splom` | `addDataPoint` no-ops — for gauges, just call `dialGauge(newValue)`/`barGauge(newValue)`/`bulletGauge(newValue)` again directly |
|
|
1530
1827
|
|
|
1531
1828
|
### 2. Graphs — `addGraphNode`
|
|
1532
1829
|
|
|
@@ -3492,30 +3789,55 @@ dataModel.addArchimateLink(link);
|
|
|
3492
3789
|
|
|
3493
3790
|
## Multi-View Chart Dashboards (`ViewsPart` / `DecisionWorkbenchPart`)
|
|
3494
3791
|
|
|
3495
|
-
`ViewsPart` (`src/workspace/parts/ViewsPart.js
|
|
3792
|
+
`ViewsPart` (`src/workspace/parts/ViewsPart.js`, type `views`) shows a set of chart **views** inside
|
|
3793
|
+
one workspace part: a tab per view, each a grid of charts with its notes underneath, and a toolbar that
|
|
3794
|
+
opens a file. A view is plain data:
|
|
3496
3795
|
|
|
3497
|
-
```
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
part: 'views',
|
|
3502
|
-
views: [
|
|
3503
|
-
{
|
|
3504
|
-
id: 'executive-summary',
|
|
3505
|
-
title: 'Executive Summary',
|
|
3506
|
-
charts: [
|
|
3507
|
-
{ title: 'System Health', chartType: 'gauge dial', data: [{ score: 94 }], options: { min: 0, max: 100 } },
|
|
3508
|
-
{ title: 'Revenue Trend', chartType: 'area', data: [...] }
|
|
3509
|
-
],
|
|
3510
|
-
notes: ['All latency thresholds met', 'Quarterly SLA target achieved']
|
|
3511
|
-
}
|
|
3512
|
-
]
|
|
3513
|
-
};
|
|
3796
|
+
```js
|
|
3797
|
+
{ id: 'summary', title: 'Summary',
|
|
3798
|
+
charts: [{ title, chartType, data, options? }], // chartType: a CHART_CATALOG key ('kpi-tile', 'bar', 'gantt', …)
|
|
3799
|
+
notes: ['plain lines the charts cannot show'] }
|
|
3514
3800
|
```
|
|
3515
3801
|
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3802
|
+
Like any part it is registered, added with `addParts`, and placed in a layout by its `partId`. It uses
|
|
3803
|
+
`ChartPart`, so it is registered with it rather than being among `BUILTIN_PARTS`:
|
|
3804
|
+
|
|
3805
|
+
```js
|
|
3806
|
+
const { registerBuiltinParts, ChartPart, ViewsPart } = RenderEngineWorkspace;
|
|
3807
|
+
registerBuiltinParts(ws);
|
|
3808
|
+
ws.registerParts([ChartPart, ViewsPart]);
|
|
3809
|
+
ws.addParts([{
|
|
3810
|
+
id: 'summary', type: 'views', title: 'Summary',
|
|
3811
|
+
views: [{
|
|
3812
|
+
id: 'health', title: 'Health',
|
|
3813
|
+
charts: [
|
|
3814
|
+
{ title: 'Availability (%)', chartType: 'kpi-tile', data: { value: 99.95 } },
|
|
3815
|
+
{ title: 'Errors by service', chartType: 'bar', data: [{ label: 'api', value: 12 }, { label: 'web', value: 3 }] },
|
|
3816
|
+
],
|
|
3817
|
+
notes: ['All latency thresholds met'],
|
|
3818
|
+
}],
|
|
3819
|
+
}]);
|
|
3820
|
+
ws.registerPerspective('Summary', { type: 'part', partId: 'summary' });
|
|
3821
|
+
```
|
|
3822
|
+
|
|
3823
|
+
- **One part, any layout.** It sits in a tab, a split, a dashboard cell or a WorkArea document beside
|
|
3824
|
+
what else the host shows; it does not register perspectives of its own.
|
|
3825
|
+
- **Composed from existing parts.** Each chart is a `ChartPart`, the notes a `ListPart`. Gauge-like
|
|
3826
|
+
charts (catalog `category: 'gauge'`, e.g. `kpi-tile`, `gauge-bar`, `bullet`) share the top row as
|
|
3827
|
+
tiles; other charts take half a row, or the whole row when a view has one (`ViewsPart.layout(views)`).
|
|
3828
|
+
- **Spec:** `views`, `open` (default true: the toolbar's file opener), `empty` (the text shown with no
|
|
3829
|
+
views). `part.read(doc)` shows a `{ views: [...] }` document from code. The views shown are kept in
|
|
3830
|
+
`spec.views`, so a serialized workspace comes back showing them.
|
|
3831
|
+
- **Other kinds of document** are subclasses overriding `viewsFrom(doc)` (and `accept`, `controls()`,
|
|
3832
|
+
`controlValues()` for options their reader takes). Why a subclass and not a reader in the spec, and
|
|
3833
|
+
why one part rather than perspectives: `documentation/WORKSPACE_ARCHITECTURE.md` (Parts).
|
|
3834
|
+
|
|
3835
|
+
`DecisionWorkbenchPart` (`src/workspace/parts/DecisionWorkbenchPart.js`, type `decision-workbench`) is
|
|
3836
|
+
the first such subclass. It reads a SADIS workbench document from decisionengine
|
|
3837
|
+
(`{ kind: 'sadis-workbench', report, plan?, before? }`) through `DecisionWorkbench` and shows its
|
|
3838
|
+
**executive**, **architecture**, **delivery**, **portfolio** and **replay** views (see *Decision
|
|
3839
|
+
Workbench* above), with toolbar fields for the calendar the delivery plan is drawn on. Spec: `ViewsPart`'s
|
|
3840
|
+
plus `document`, `start`, `periodDays`. `examples/decision-workbench.html` hosts one.
|
|
3519
3841
|
|
|
3520
3842
|
## Where to go next
|
|
3521
3843
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "ArchiMate modeller, data modeller, diagram editor, OLAP cube & pivot chart studio, and 50+ type chart library for the browser, with no runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -33,7 +33,8 @@ import { ExchangeFileFormat } from './archimate/ExchangeFileFormat.js';
|
|
|
33
33
|
import { ArchimateCsvFileFormat } from './archimate/ArchimateCsvFileFormat.js';
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* ArchimateDiagramEditor —
|
|
36
|
+
* ArchimateDiagramEditor — legacy GraphDiagram-based ArchiMate editor extending DiagramEditor.
|
|
37
|
+
* @deprecated Use ArchimateCanvasEditor (or ArchimateCanvasPart) for ArchiMate editing. RenderEngine.archimateDiagram() remains for read-only charts.
|
|
37
38
|
*
|
|
38
39
|
* Encapsulates: ArchiMate model, tree navigator, diagram tabs, rendering, controls wiring.
|
|
39
40
|
* Inherits: common rendering, drag/drop, toolbar, tree, filtering from DiagramEditor.
|
package/src/charting/Edge.js
CHANGED
|
@@ -117,4 +117,26 @@ export class Edge {
|
|
|
117
117
|
clearWaypoints() {
|
|
118
118
|
this.waypoints = [];
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
/** The edge as plain JSON: its endpoints, label, waypoints, side pins and style overrides. */
|
|
122
|
+
toJSON() {
|
|
123
|
+
return {
|
|
124
|
+
id: this.id,
|
|
125
|
+
sourceId: this.sourceId,
|
|
126
|
+
targetId: this.targetId,
|
|
127
|
+
label: this.label,
|
|
128
|
+
waypoints: this.waypoints.map(w => ({ x: w.x, y: w.y })),
|
|
129
|
+
sourceSide: this.sourceSide,
|
|
130
|
+
targetSide: this.targetSide,
|
|
131
|
+
visible: this.visible,
|
|
132
|
+
...this.styleOverrides(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Reconstruct an Edge from plain JSON. */
|
|
137
|
+
static fromJSON(json) {
|
|
138
|
+
if (!json) throw new TypeError('Edge.fromJSON requires a JSON object');
|
|
139
|
+
const { id, sourceId, targetId, ...rest } = json;
|
|
140
|
+
return new Edge(id, sourceId, targetId, rest);
|
|
141
|
+
}
|
|
120
142
|
}
|
|
@@ -1034,15 +1034,28 @@ export class ArchimateCanvasEditor extends DiagramEditor {
|
|
|
1034
1034
|
return element ? treeLayerOf(element) : 'other';
|
|
1035
1035
|
}
|
|
1036
1036
|
|
|
1037
|
-
/** An element's box holds any box
|
|
1037
|
+
/** An element's box holds any box; a group holds any box or nested group; on a canvas, a block holds boxes. */
|
|
1038
1038
|
canHold(parentId, childId) {
|
|
1039
|
+
if (!parentId || !childId || parentId === childId) return false;
|
|
1039
1040
|
const parent = this.templates.get(parentId), child = this.templates.get(childId);
|
|
1040
|
-
if (!parent
|
|
1041
|
+
if (!parent || !child) return false;
|
|
1042
|
+
if (child instanceof ViewGroupTemplate && parent instanceof ViewGroupTemplate) {
|
|
1043
|
+
return !this.isAncestor(childId, parentId);
|
|
1044
|
+
}
|
|
1045
|
+
if (parent instanceof ViewGroupTemplate) {
|
|
1046
|
+
return !this.isAncestor(childId, parentId);
|
|
1047
|
+
}
|
|
1048
|
+
if (child instanceof ViewGroupTemplate) return false;
|
|
1049
|
+
if (!parent.instance || !child.instance) return false;
|
|
1041
1050
|
return !!this._elementOf(parentId) || parent.instance.content?.archiVisualType === 'canvas-model-block';
|
|
1042
1051
|
}
|
|
1043
1052
|
|
|
1044
|
-
/** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them. */
|
|
1053
|
+
/** An element dropped on another nests there when a relationship a nesting stands for (NESTING_KINDS) is allowed between them, or when dropped into a group. */
|
|
1045
1054
|
nestsOnDrop(parentId, childId) {
|
|
1055
|
+
const parent = this.templates.get(parentId);
|
|
1056
|
+
if (parent instanceof ViewGroupTemplate) {
|
|
1057
|
+
return this.canHold(parentId, childId);
|
|
1058
|
+
}
|
|
1046
1059
|
if (!this._elementOf(parentId) || !this._elementOf(childId)) return false;
|
|
1047
1060
|
return this.allowedKinds(parentId, childId).some(kind => NESTING_KINDS.includes(kind));
|
|
1048
1061
|
}
|
|
@@ -1050,13 +1063,16 @@ export class ArchimateCanvasEditor extends DiagramEditor {
|
|
|
1050
1063
|
nestCommand(childId, parentId) {
|
|
1051
1064
|
const child = this.templates.get(childId)?.instance;
|
|
1052
1065
|
if (!child) return null;
|
|
1053
|
-
|
|
1066
|
+
const parentTemplate = parentId ? this.templates.get(parentId) : null;
|
|
1067
|
+
const parentTarget = parentTemplate?.instance ?? (parentTemplate instanceof ViewGroupTemplate ? parentTemplate : null);
|
|
1068
|
+
return new NestInstanceCommand(child, parentTarget);
|
|
1054
1069
|
}
|
|
1055
1070
|
|
|
1056
|
-
/** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). */
|
|
1071
|
+
/** After nesting an element in another: offer the relationship the nesting stands for (nestingKinds). Groups have no relationship. */
|
|
1057
1072
|
nested(changes) {
|
|
1058
1073
|
let chain = Promise.resolve();
|
|
1059
1074
|
for (const { childId, parentId } of changes) {
|
|
1075
|
+
if (this.templates.get(parentId) instanceof ViewGroupTemplate) continue;
|
|
1060
1076
|
chain = chain.then(() => this.offerNestingRelationship(parentId, childId)); // taken out: nothing to offer
|
|
1061
1077
|
}
|
|
1062
1078
|
return chain;
|
|
@@ -47,8 +47,11 @@ export class ViewGroupTemplate extends GlyphTemplate {
|
|
|
47
47
|
return this.view.model.getFolder(this.id);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
get name() { return this.folder?.name ?? 'Group'; }
|
|
51
|
-
set name(value) {
|
|
50
|
+
get name() { return this.folder?.name ?? this.layout?.name ?? 'Group'; }
|
|
51
|
+
set name(value) {
|
|
52
|
+
if (this.folder) this.folder.name = value || 'Group';
|
|
53
|
+
if (this.layout) this.layout.name = value || 'Group';
|
|
54
|
+
}
|
|
52
55
|
|
|
53
56
|
// The layout keeps the top-left corner; a template is placed by its centre.
|
|
54
57
|
get x() { return this.layout.x + this.width / 2; }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ModelFileFormat } from '../ModelFileFormat.js';
|
|
2
|
+
import { DiagramModel } from './DiagramModel.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DiagramJsonFileFormat — a diagram model as a file (`.diagram.json` or `.json`):
|
|
6
|
+
* its name, palette category, line style defaults, views with templates, edges,
|
|
7
|
+
* waypoints and arrangements (DiagramModel#toJSON), read back by DiagramModel.fromJSON.
|
|
8
|
+
*/
|
|
9
|
+
export class DiagramJsonFileFormat extends ModelFileFormat {
|
|
10
|
+
get label() { return '.diagram.json'; }
|
|
11
|
+
get accept() { return '.diagram.json,.json'; }
|
|
12
|
+
get fileName() { return 'diagrams.diagram.json'; }
|
|
13
|
+
get featureName() { return 'DiagramJson'; }
|
|
14
|
+
|
|
15
|
+
/** JSON with a diagram model's shape: templateType and views array, or views array of diagrams. */
|
|
16
|
+
recognizes(text) {
|
|
17
|
+
try {
|
|
18
|
+
const json = JSON.parse(text);
|
|
19
|
+
return !!json && typeof json === 'object' && (Array.isArray(json.views) || typeof json.templateType === 'string');
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export(model) {
|
|
26
|
+
return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
import(text) {
|
|
30
|
+
return DiagramModel.fromJSON(JSON.parse(text));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -36,4 +36,43 @@ export class DiagramModel extends ViewSetModel {
|
|
|
36
36
|
setTemplateType(templateType) {
|
|
37
37
|
this.templateType = templateType;
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** The diagram model as plain JSON: its name, templateType, line style defaults, active view and views. */
|
|
41
|
+
toJSON() {
|
|
42
|
+
return {
|
|
43
|
+
version: 1,
|
|
44
|
+
name: this.name,
|
|
45
|
+
templateType: this.templateType,
|
|
46
|
+
edgeStyle: JSON.parse(JSON.stringify(this.edgeStyle ?? {})),
|
|
47
|
+
currentViewId: this.currentViewId,
|
|
48
|
+
views: this.views().map(v => v.toJSON()),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reconstruct a DiagramModel from plain JSON. */
|
|
53
|
+
static fromJSON(json, historyOptions = {}) {
|
|
54
|
+
if (!json) throw new TypeError('DiagramModel.fromJSON requires a JSON object');
|
|
55
|
+
const model = new DiagramModel({
|
|
56
|
+
name: json.name ?? '',
|
|
57
|
+
templateType: json.templateType ?? 'generic',
|
|
58
|
+
edgeStyle: json.edgeStyle ?? {},
|
|
59
|
+
historyOptions,
|
|
60
|
+
});
|
|
61
|
+
const views = (json.views ?? []).map(vData => DiagramView.fromJSON(vData, model.historyOptions));
|
|
62
|
+
for (const view of views) {
|
|
63
|
+
model._insertView(model._adopt(view), { current: view.id === json.currentViewId });
|
|
64
|
+
}
|
|
65
|
+
if (json.currentViewId && model._views.has(json.currentViewId)) {
|
|
66
|
+
model.currentViewId = json.currentViewId;
|
|
67
|
+
} else if (model._views.size > 0 && !model.currentViewId) {
|
|
68
|
+
model.currentViewId = model._views.keys().next().value;
|
|
69
|
+
}
|
|
70
|
+
const maxNum = Math.max(0, ...model.views().map(v => {
|
|
71
|
+
const m = String(v.id).match(/\d+$/);
|
|
72
|
+
return m ? parseInt(m[0], 10) : 0;
|
|
73
|
+
}));
|
|
74
|
+
model._nextId = maxNum + 1;
|
|
75
|
+
model.markSaved();
|
|
76
|
+
return model;
|
|
77
|
+
}
|
|
39
78
|
}
|