@ti-engine/web-framework 1.19.0 → 1.20.0
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/.env +4 -4
- package/CHANGELOG.md +384 -353
- package/README.md +73 -73
- package/bin/build/post-install.js +18 -18
- package/bin/localization/web-server-labels.json +27 -27
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
- package/bin/static/fragments/components/component-notification-bar.html +21 -21
- package/bin/static/fragments/components/component-sidebar.html +33 -33
- package/bin/static/fragments/components/component-tooltip.html +10 -10
- package/bin/static/fragments/components/component-topbar.html +5 -5
- package/bin/static/fragments/frame-administration.html +2 -2
- package/bin/static/fragments/frame-application.html +18 -18
- package/bin/static/fragments/frame-dashboard.html +2 -2
- package/bin/static/fragments/frame-login.html +119 -119
- package/bin/static/fragments/frame-not-found.html +2 -2
- package/bin/static/fragments/frame-profile.html +2 -2
- package/bin/static/index.html +22 -22
- package/bin/static/scripts/ti-charts.js +1591 -1591
- package/bin/static/scripts/ti-framework.css +3194 -3194
- package/bin/static/scripts/ti-framework.js +1427 -1427
- package/bin/static/scripts/ti-theme-black-glass.css +216 -216
- package/bin/static/scripts/ti-theme-daylight.css +87 -87
- package/bin/web-app-manager.js +660 -663
- package/bin/web-server.js +936 -937
- package/bin/web-server.json +48 -48
- package/components/admin-config-handlers.js +95 -92
- package/components/auth-manager.js +438 -442
- package/components/authorization.js +135 -135
- package/components/config-change-notifier.js +98 -98
- package/components/config-registry.js +257 -260
- package/components/config-service.js +363 -360
- package/components/config-store.js +244 -246
- package/components/definitions.types.js +28 -26
- package/components/session-store.js +113 -110
- package/components/user.js +134 -132
- package/components/web-config-env.js +85 -85
- package/components/web-handlers.js +803 -800
- package/package.json +139 -67
- package/types/bin/web-app-manager.d.ts +194 -0
- package/types/bin/web-server.d.ts +373 -0
- package/types/components/admin-config-handlers.d.ts +11 -0
- package/types/components/auth-manager.d.ts +125 -0
- package/types/components/authorization.d.ts +54 -0
- package/types/components/config-change-notifier.d.ts +73 -0
- package/types/components/config-registry.d.ts +149 -0
- package/types/components/config-service.d.ts +218 -0
- package/types/components/config-store.d.ts +128 -0
- package/types/components/definitions.types.d.ts +31 -0
- package/types/components/session-store.d.ts +56 -0
- package/types/components/user.d.ts +83 -0
- package/types/components/web-config-env.d.ts +17 -0
- package/types/components/web-handlers.d.ts +23 -0
|
@@ -1,1591 +1,1591 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
-
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
-
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
-
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
-
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
"use strict";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* ti-charts — hand-rolled, themeable, CSP-safe SVG chart primitives for the ti-engine web apps.
|
|
13
|
-
* Phase 0 ships three primitives: gauge, bars, stat. All scale/path/format math lives in the pure
|
|
14
|
-
* helpers below (unit-tested via node:test); the renderers build SVG via createElementNS +
|
|
15
|
-
* setAttribute only. Dynamic visuals are presentation attributes or CSS classes — never
|
|
16
|
-
* element.style.* (the single sanctioned exception is element.style.setProperty( "--var", … )).
|
|
17
|
-
*/
|
|
18
|
-
const TiCharts = ( function () {
|
|
19
|
-
|
|
20
|
-
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Clamps a 0..1 value and maps it onto an arc beginning at `startAngle` (degrees, SVG screen
|
|
24
|
-
* convention: 0°=east, +y=down/clockwise) spanning `sweep` degrees clockwise.
|
|
25
|
-
* @param {number} value 0..1
|
|
26
|
-
* @param {number} startAngle degrees
|
|
27
|
-
* @param {number} sweep degrees (positive = clockwise)
|
|
28
|
-
* @returns {number}
|
|
29
|
-
*/
|
|
30
|
-
function gaugeValueToAngle( value, startAngle, sweep ) {
|
|
31
|
-
let v = value;
|
|
32
|
-
if ( v < 0 || Number.isNaN( v ) ) {
|
|
33
|
-
v = 0;
|
|
34
|
-
}
|
|
35
|
-
if ( v > 1 ) {
|
|
36
|
-
v = 1;
|
|
37
|
-
}
|
|
38
|
-
return startAngle + ( v * sweep );
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Converts polar (cx, cy, r, angleDeg, degrees) to a Cartesian { x, y } point.
|
|
42
|
-
function _polar( cx, cy, r, angleDeg ) {
|
|
43
|
-
const a = ( angleDeg * Math.PI ) / 180;
|
|
44
|
-
return { x: cx + ( r * Math.cos( a ) ), y: cy + ( r * Math.sin( a ) ) };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Rounds to 2 decimals to keep SVG path strings compact.
|
|
48
|
-
function _round( n ) {
|
|
49
|
-
return Math.round( n * 100 ) / 100;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Builds an SVG path "d" for a gauge value arc on a circle radius r centred at (cx,cy),
|
|
54
|
-
* running clockwise from `startAngle` for value·sweep degrees.
|
|
55
|
-
* @param {number} value 0..1
|
|
56
|
-
* @param {{cx:number,cy:number,r:number,startAngle:number,sweep:number}} opts
|
|
57
|
-
* @returns {string}
|
|
58
|
-
*/
|
|
59
|
-
function gaugeArcPath( value, opts ) {
|
|
60
|
-
const cx = opts.cx, cy = opts.cy, r = opts.r;
|
|
61
|
-
const startAngle = opts.startAngle, sweep = opts.sweep;
|
|
62
|
-
const endAngle = gaugeValueToAngle( value, startAngle, sweep );
|
|
63
|
-
const start = _polar( cx, cy, r, startAngle );
|
|
64
|
-
const end = _polar( cx, cy, r, endAngle );
|
|
65
|
-
const spanned = endAngle - startAngle;
|
|
66
|
-
const largeArc = ( spanned > 180 ) ? 1 : 0;
|
|
67
|
-
const sweepFlag = 1; // clockwise
|
|
68
|
-
return "M" + _round( start.x ) + " " + _round( start.y ) +
|
|
69
|
-
" A" + r + " " + r + " 0 " + largeArc + " " + sweepFlag + " " + _round( end.x ) + " " + _round( end.y );
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Lays out horizontal/stacked bar segments along a track. Segment widths are proportional to
|
|
74
|
-
* each segment's `v`; offsets accumulate left-to-right. With `opts.total` the denominator is
|
|
75
|
-
* fixed (e.g. a roster size) so partial rows do not stretch to fill; otherwise the segments
|
|
76
|
-
* normalize to their own sum.
|
|
77
|
-
* @param {Array<{key:string,v:number,tone?:string}>} segments
|
|
78
|
-
* @param {{width:number,total?:number}} opts
|
|
79
|
-
* @returns {Array<{key:string,tone:string,x:number,width:number}>}
|
|
80
|
-
*/
|
|
81
|
-
function barSegments( segments, opts ) {
|
|
82
|
-
const width = opts.width;
|
|
83
|
-
let denom = opts.total;
|
|
84
|
-
if ( denom === undefined || denom === null ) {
|
|
85
|
-
denom = 0;
|
|
86
|
-
for ( let i = 0; i < segments.length; i++ ) {
|
|
87
|
-
denom += ( segments[ i ].v || 0 );
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const out = [];
|
|
91
|
-
let cursor = 0;
|
|
92
|
-
for ( let i = 0; i < segments.length; i++ ) {
|
|
93
|
-
const v = segments[ i ].v || 0;
|
|
94
|
-
const w = ( denom > 0 ) ? _round( ( v / denom ) * width ) : 0;
|
|
95
|
-
out.push( {
|
|
96
|
-
key: segments[ i ].key,
|
|
97
|
-
tone: segments[ i ].tone || "",
|
|
98
|
-
x: _round( cursor ),
|
|
99
|
-
width: w
|
|
100
|
-
} );
|
|
101
|
-
cursor += w;
|
|
102
|
-
}
|
|
103
|
-
return out;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Formats a 0..1 ratio as a percent string. Clamps to [0,1]; null/undefined/NaN → em dash.
|
|
108
|
-
* @param {number} ratio
|
|
109
|
-
* @param {number} [digits=0]
|
|
110
|
-
* @returns {string}
|
|
111
|
-
*/
|
|
112
|
-
function formatPercent( ratio, digits ) {
|
|
113
|
-
if ( ratio === null || ratio === undefined || Number.isNaN( ratio ) ) {
|
|
114
|
-
return "—";
|
|
115
|
-
}
|
|
116
|
-
const d = ( typeof digits === "number" ) ? digits : 0;
|
|
117
|
-
let r = ratio;
|
|
118
|
-
if ( r < 0 ) {
|
|
119
|
-
r = 0;
|
|
120
|
-
}
|
|
121
|
-
if ( r > 1 ) {
|
|
122
|
-
r = 1;
|
|
123
|
-
}
|
|
124
|
-
return ( r * 100 ).toFixed( d ) + "%";
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Formats a number to fixed digits (default 0). null/undefined/NaN → em dash.
|
|
129
|
-
* @param {number} value
|
|
130
|
-
* @param {number} [digits=0]
|
|
131
|
-
* @returns {string}
|
|
132
|
-
*/
|
|
133
|
-
function formatNumber( value, digits ) {
|
|
134
|
-
if ( value === null || value === undefined || Number.isNaN( value ) ) {
|
|
135
|
-
return "—";
|
|
136
|
-
}
|
|
137
|
-
const d = ( typeof digits === "number" ) ? digits : 0;
|
|
138
|
-
return Number( value ).toFixed( d );
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Creates an SVG-namespaced element and applies every attribute via setAttribute (CSP-legal).
|
|
143
|
-
* Never touches element.style. Pass `doc` to inject a document in tests.
|
|
144
|
-
* @param {string} tag
|
|
145
|
-
* @param {Object<string,string|number>} attrs
|
|
146
|
-
* @param {Document} [doc]
|
|
147
|
-
* @returns {Element}
|
|
148
|
-
*/
|
|
149
|
-
function svgEl( tag, attrs, doc ) {
|
|
150
|
-
const d = doc || ( typeof document !== "undefined" ? document : null );
|
|
151
|
-
const el = d.createElementNS( SVG_NS, tag );
|
|
152
|
-
if ( attrs ) {
|
|
153
|
-
const keys = Object.keys( attrs );
|
|
154
|
-
for ( let i = 0; i < keys.length; i++ ) {
|
|
155
|
-
el.setAttribute( keys[ i ], String( attrs[ keys[ i ] ] ) );
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return el;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Builds the visually-hidden HTML <table class="ti-chart-sr"> a11y mirror placed beside the <svg>.
|
|
163
|
-
* @param {string[]} headers
|
|
164
|
-
* @param {Array<Array<string|number>>} rows
|
|
165
|
-
* @param {Document} [doc]
|
|
166
|
-
* @returns {HTMLTableElement}
|
|
167
|
-
*/
|
|
168
|
-
function buildSrTable( headers, rows, doc ) {
|
|
169
|
-
const d = doc || ( typeof document !== "undefined" ? document : null );
|
|
170
|
-
const table = d.createElement( "table" );
|
|
171
|
-
table.setAttribute( "class", "ti-chart-sr" );
|
|
172
|
-
const thead = d.createElement( "thead" );
|
|
173
|
-
const htr = d.createElement( "tr" );
|
|
174
|
-
for ( let i = 0; i < headers.length; i++ ) {
|
|
175
|
-
const th = d.createElement( "th" );
|
|
176
|
-
th.textContent = String( headers[ i ] );
|
|
177
|
-
htr.appendChild( th );
|
|
178
|
-
}
|
|
179
|
-
thead.appendChild( htr );
|
|
180
|
-
table.appendChild( thead );
|
|
181
|
-
const tbody = d.createElement( "tbody" );
|
|
182
|
-
for ( let r = 0; r < rows.length; r++ ) {
|
|
183
|
-
const tr = d.createElement( "tr" );
|
|
184
|
-
for ( let c = 0; c < rows[ r ].length; c++ ) {
|
|
185
|
-
const td = d.createElement( "td" );
|
|
186
|
-
td.textContent = String( rows[ r ][ c ] );
|
|
187
|
-
tr.appendChild( td );
|
|
188
|
-
}
|
|
189
|
-
tbody.appendChild( tr );
|
|
190
|
-
}
|
|
191
|
-
table.appendChild( tbody );
|
|
192
|
-
return table;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Lays out Coverage gauge sub-rows: each row's coverage ratio (explicit `value`, else n/total)
|
|
197
|
-
* and its bar width along the track. Guards total=0.
|
|
198
|
-
* @param {Array<{id:string,name:string,value?:number,n?:number,total?:number,tone?:string}>} rows
|
|
199
|
-
* @param {{width:number}} opts
|
|
200
|
-
* @returns {Array<{id:string,label:string,ratio:number,width:number,tone:string}>}
|
|
201
|
-
*/
|
|
202
|
-
function gaugeRowsLayout( rows, opts ) {
|
|
203
|
-
const width = opts.width;
|
|
204
|
-
const out = [];
|
|
205
|
-
for ( let i = 0; i < rows.length; i++ ) {
|
|
206
|
-
const row = rows[ i ];
|
|
207
|
-
let ratio;
|
|
208
|
-
if ( typeof row.value === "number" ) {
|
|
209
|
-
ratio = row.value;
|
|
210
|
-
} else if ( row.total ) {
|
|
211
|
-
ratio = ( row.n || 0 ) / row.total;
|
|
212
|
-
} else {
|
|
213
|
-
ratio = 0;
|
|
214
|
-
}
|
|
215
|
-
if ( Number.isNaN( ratio ) ) {
|
|
216
|
-
ratio = 0;
|
|
217
|
-
}
|
|
218
|
-
if ( ratio < 0 ) {
|
|
219
|
-
ratio = 0;
|
|
220
|
-
}
|
|
221
|
-
if ( ratio > 1 ) {
|
|
222
|
-
ratio = 1;
|
|
223
|
-
}
|
|
224
|
-
out.push( {
|
|
225
|
-
id: row.id,
|
|
226
|
-
label: row.name || row.id,
|
|
227
|
-
ratio: ratio,
|
|
228
|
-
width: _round( ratio * width ),
|
|
229
|
-
tone: row.tone || ""
|
|
230
|
-
} );
|
|
231
|
-
}
|
|
232
|
-
return out;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Clamps n into [lo,hi].
|
|
236
|
-
function _clamp( n, lo, hi ) {
|
|
237
|
-
if ( Number.isNaN( n ) ) {
|
|
238
|
-
return lo;
|
|
239
|
-
}
|
|
240
|
-
if ( n < lo ) {
|
|
241
|
-
return lo;
|
|
242
|
-
}
|
|
243
|
-
if ( n > hi ) {
|
|
244
|
-
return hi;
|
|
245
|
-
}
|
|
246
|
-
return n;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Maps scatter points + reference geometry from a data domain into the SVG plot box. Pure: no DOM.
|
|
251
|
-
* x grows left→right; y is INVERTED (data max at the top). Out-of-domain points are clamped into the box.
|
|
252
|
-
* Default domain is grade-weight space 0..1.3. Bubble radius scales by z when `options.bubble === "z"`.
|
|
253
|
-
*
|
|
254
|
-
* @param {Array<{id,x,y,z?,r?,tone?,label?}>} points
|
|
255
|
-
* @param {Object} [opts] {width=100,height=100,pad=10,domain:{xMin,xMax,yMin,yMax},midX?,midY?,bubble?,zMax?,rDefault?,rMin?,rMax?,anonymize?}
|
|
256
|
-
* @returns {{points:Array,diagonal:Object|null,midX:Object|null,midY:Object|null,bounds:Object}}
|
|
257
|
-
*/
|
|
258
|
-
function scatterLayout( points, opts ) {
|
|
259
|
-
const o = opts || {};
|
|
260
|
-
const width = ( typeof o.width === "number" ) ? o.width : 100;
|
|
261
|
-
const height = ( typeof o.height === "number" ) ? o.height : 100;
|
|
262
|
-
const pad = ( typeof o.pad === "number" ) ? o.pad : 10;
|
|
263
|
-
const dom = o.domain || {};
|
|
264
|
-
const xMin = ( typeof dom.xMin === "number" ) ? dom.xMin : 0;
|
|
265
|
-
const xMax = ( typeof dom.xMax === "number" ) ? dom.xMax : 1.3;
|
|
266
|
-
const yMin = ( typeof dom.yMin === "number" ) ? dom.yMin : 0;
|
|
267
|
-
const yMax = ( typeof dom.yMax === "number" ) ? dom.yMax : 1.3;
|
|
268
|
-
const plotW = width - ( 2 * pad );
|
|
269
|
-
const plotH = height - ( 2 * pad );
|
|
270
|
-
const xSpan = ( xMax - xMin ) || 1;
|
|
271
|
-
const ySpan = ( yMax - yMin ) || 1;
|
|
272
|
-
const rDefault = ( typeof o.rDefault === "number" ) ? o.rDefault : 2.2;
|
|
273
|
-
const rMin = ( typeof o.rMin === "number" ) ? o.rMin : 1.4;
|
|
274
|
-
const rMax = ( typeof o.rMax === "number" ) ? o.rMax : 4;
|
|
275
|
-
const zMax = ( typeof o.zMax === "number" && o.zMax > 0 ) ? o.zMax : 1;
|
|
276
|
-
const anonymize = !!o.anonymize;
|
|
277
|
-
|
|
278
|
-
const xScale = ( x ) => _round( pad + ( ( _clamp( x, xMin, xMax ) - xMin ) / xSpan ) * plotW );
|
|
279
|
-
const yScale = ( y ) => _round( pad + plotH - ( ( _clamp( y, yMin, yMax ) - yMin ) / ySpan ) * plotH );
|
|
280
|
-
|
|
281
|
-
const laid = [];
|
|
282
|
-
const list = Array.isArray( points ) ? points : [];
|
|
283
|
-
for ( let i = 0; i < list.length; i++ ) {
|
|
284
|
-
const p = list[ i ];
|
|
285
|
-
const x = ( typeof p.x === "number" ) ? p.x : 0;
|
|
286
|
-
const y = ( typeof p.y === "number" ) ? p.y : 0;
|
|
287
|
-
const z = ( typeof p.z === "number" ) ? p.z : null;
|
|
288
|
-
let r = ( typeof p.r === "number" ) ? p.r : rDefault;
|
|
289
|
-
if ( o.bubble === "z" && z !== null ) {
|
|
290
|
-
r = _round( rMin + ( _clamp( z, 0, zMax ) / zMax ) * ( rMax - rMin ) );
|
|
291
|
-
}
|
|
292
|
-
laid.push( {
|
|
293
|
-
id: p.id,
|
|
294
|
-
label: anonymize ? "" : ( ( p.label !== undefined ) ? p.label : null ),
|
|
295
|
-
tone: p.tone || "",
|
|
296
|
-
x: x, y: y, z: z,
|
|
297
|
-
cx: xScale( x ), cy: yScale( y ), r: r
|
|
298
|
-
} );
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// The diagonal (y=x reference) is opt-in via opts.diagonal; the renderer forwards data.diagonal.
|
|
302
|
-
let diagonal = null;
|
|
303
|
-
if ( o.diagonal ) {
|
|
304
|
-
diagonal = { x1: xScale( xMin ), y1: yScale( yMin ), x2: xScale( xMax ), y2: yScale( yMax ) };
|
|
305
|
-
}
|
|
306
|
-
const midX = ( typeof o.midX === "number" ) ? { x: xScale( o.midX ), y1: _round( pad ), y2: _round( pad + plotH ) } : null;
|
|
307
|
-
const midY = ( typeof o.midY === "number" ) ? { x1: _round( pad ), x2: _round( pad + plotW ), y: yScale( o.midY ) } : null;
|
|
308
|
-
|
|
309
|
-
return { points: laid, diagonal: diagonal, midX: midX, midY: midY, bounds: { pad: pad, plotW: plotW, plotH: plotH, width: width, height: height } };
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Nearest-rank quantile bucket (1..buckets) for value `v` among `values`. All-equal (or empty) inputs collapse to
|
|
314
|
-
* the middle bucket so a flat heatmap row does not render as all-max. Pure.
|
|
315
|
-
* @param {Array<number>} values
|
|
316
|
-
* @param {number} v
|
|
317
|
-
* @param {number} [buckets=5]
|
|
318
|
-
* @returns {number}
|
|
319
|
-
*/
|
|
320
|
-
function quantileBucket( values, v, buckets ) {
|
|
321
|
-
const b = ( typeof buckets === "number" && buckets > 0 ) ? buckets : 5;
|
|
322
|
-
const arr = Array.isArray( values ) ? values.filter( ( n ) => typeof n === "number" && !Number.isNaN( n ) ) : [];
|
|
323
|
-
const n = arr.length;
|
|
324
|
-
if ( n === 0 ) {
|
|
325
|
-
return Math.ceil( b / 2 );
|
|
326
|
-
}
|
|
327
|
-
let min = arr[ 0 ], max = arr[ 0 ];
|
|
328
|
-
for ( let i = 1; i < n; i++ ) {
|
|
329
|
-
if ( arr[ i ] < min ) {
|
|
330
|
-
min = arr[ i ];
|
|
331
|
-
}
|
|
332
|
-
if ( arr[ i ] > max ) {
|
|
333
|
-
max = arr[ i ];
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
if ( min === max ) {
|
|
337
|
-
return Math.ceil( b / 2 );
|
|
338
|
-
}
|
|
339
|
-
let le = 0;
|
|
340
|
-
for ( let i = 0; i < n; i++ ) {
|
|
341
|
-
if ( arr[ i ] <= v ) {
|
|
342
|
-
le += 1;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return _clamp( Math.ceil( ( le / n ) * b ), 1, b );
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Lays out a heatmap grid. Sequential mode buckets each cell's `v` into 1..buckets (quantileBucket); diverging
|
|
350
|
-
* mode classifies each cell's `delta` as pos/neg/zero with a 0..1 magnitude vs the cohort max |delta|. Pure.
|
|
351
|
-
* @param {Array<{id,label}>} rows
|
|
352
|
-
* @param {Array<{id,label}>} cols
|
|
353
|
-
* @param {Array<{r,c,v?,n?,expected?,delta?,suppressed?}>} cells
|
|
354
|
-
* @param {Object} [opts] {width=100,rowLabelW=18,colLabelH=8,cellH=10,scale,buckets}
|
|
355
|
-
* @returns {{cells:Array,rowLabels:Array,colLabels:Array,gridW:number,gridH:number,width:number,height:number}}
|
|
356
|
-
*/
|
|
357
|
-
function heatmapLayout( rows, cols, cells, opts ) {
|
|
358
|
-
const o = opts || {};
|
|
359
|
-
const width = ( typeof o.width === "number" ) ? o.width : 100;
|
|
360
|
-
const rowLabelW = ( typeof o.rowLabelW === "number" ) ? o.rowLabelW : 18;
|
|
361
|
-
const colLabelH = ( typeof o.colLabelH === "number" ) ? o.colLabelH : 8;
|
|
362
|
-
const cellH = ( typeof o.cellH === "number" ) ? o.cellH : 10;
|
|
363
|
-
const scale = ( o.scale === "diverging" ) ? "diverging" : "sequential";
|
|
364
|
-
const buckets = ( typeof o.buckets === "number" ) ? o.buckets : 5;
|
|
365
|
-
const R = Array.isArray( rows ) ? rows : [];
|
|
366
|
-
const C = Array.isArray( cols ) ? cols : [];
|
|
367
|
-
const cellList = Array.isArray( cells ) ? cells : [];
|
|
368
|
-
const M = C.length;
|
|
369
|
-
const gridW = width - rowLabelW;
|
|
370
|
-
const cellW = ( M > 0 ) ? _round( gridW / M ) : 0;
|
|
371
|
-
const gridH = R.length * cellH;
|
|
372
|
-
|
|
373
|
-
const seqValues = cellList.filter( ( cell ) => !cell.suppressed && typeof cell.v === "number" ).map( ( cell ) => cell.v );
|
|
374
|
-
let maxAbs = 0;
|
|
375
|
-
for ( let i = 0; i < cellList.length; i++ ) {
|
|
376
|
-
const d = cellList[ i ].delta;
|
|
377
|
-
if ( typeof d === "number" && Math.abs( d ) > maxAbs ) {
|
|
378
|
-
maxAbs = Math.abs( d );
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const laidCells = cellList.map( ( cell ) => {
|
|
383
|
-
const out = {
|
|
384
|
-
r: cell.r, c: cell.c,
|
|
385
|
-
x: _round( rowLabelW + ( cell.c * cellW ) ),
|
|
386
|
-
y: _round( colLabelH + ( cell.r * cellH ) ),
|
|
387
|
-
w: cellW, h: cellH,
|
|
388
|
-
v: ( typeof cell.v === "number" ) ? cell.v : null,
|
|
389
|
-
n: ( typeof cell.n === "number" ) ? cell.n : null,
|
|
390
|
-
expected: ( typeof cell.expected === "number" ) ? cell.expected : null,
|
|
391
|
-
delta: ( typeof cell.delta === "number" ) ? cell.delta : null,
|
|
392
|
-
suppressed: !!cell.suppressed
|
|
393
|
-
};
|
|
394
|
-
if ( !out.suppressed ) {
|
|
395
|
-
if ( scale === "sequential" ) {
|
|
396
|
-
out.bucket = quantileBucket( seqValues, out.v, buckets );
|
|
397
|
-
} else {
|
|
398
|
-
const d = out.delta || 0;
|
|
399
|
-
out.sign = ( d > 0 ) ? "pos" : ( d < 0 ? "neg" : "zero" );
|
|
400
|
-
out.mag = ( maxAbs > 0 ) ? _round( Math.min( 1, Math.abs( d ) / maxAbs ) ) : 0;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return out;
|
|
404
|
-
} );
|
|
405
|
-
|
|
406
|
-
const rowLabels = R.map( ( row, i ) => ( {
|
|
407
|
-
id: row.id,
|
|
408
|
-
label: row.label || row.id,
|
|
409
|
-
x: _round( rowLabelW - 1 ),
|
|
410
|
-
y: _round( colLabelH + ( i * cellH ) + ( cellH / 2 ) )
|
|
411
|
-
} ) );
|
|
412
|
-
const colLabels = C.map( ( col, i ) => ( {
|
|
413
|
-
id: col.id,
|
|
414
|
-
label: col.label || col.id,
|
|
415
|
-
x: _round( rowLabelW + ( i * cellW ) + ( cellW / 2 ) ),
|
|
416
|
-
y: _round( colLabelH - 2 )
|
|
417
|
-
} ) );
|
|
418
|
-
|
|
419
|
-
return { cells: laidCells, rowLabels: rowLabels, colLabels: colLabels, gridW: gridW, gridH: gridH, width: width, height: colLabelH + gridH };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Lays out box-plots (one box per group) over a score domain (default 0..150). Score→y is INVERTED (domain.max at
|
|
424
|
-
* the top). Each group maps its five-number summary + optional expected/mean markers; global `reference` lines map
|
|
425
|
-
* too. Suppressed groups carry through without box geometry. Pure.
|
|
426
|
-
* @param {Array<{id,label,min,q1,median,q3,max,n?,mean?,expected?,suppressed?}>} groups
|
|
427
|
-
* @param {Object} [opts] {width=100,height=70,pad=8,padLeft=12,padBottom=10,domain:{min,max},reference:[{v,label}]}
|
|
428
|
-
* @returns {{boxes:Array,refs:Array,axis:Object,width:number,height:number}}
|
|
429
|
-
*/
|
|
430
|
-
function boxLayout( groups, opts ) {
|
|
431
|
-
const o = opts || {};
|
|
432
|
-
const width = ( typeof o.width === "number" ) ? o.width : 100;
|
|
433
|
-
const height = ( typeof o.height === "number" ) ? o.height : 70;
|
|
434
|
-
const pad = ( typeof o.pad === "number" ) ? o.pad : 8;
|
|
435
|
-
const padLeft = ( typeof o.padLeft === "number" ) ? o.padLeft : 12;
|
|
436
|
-
const padBottom = ( typeof o.padBottom === "number" ) ? o.padBottom : 10;
|
|
437
|
-
const dom = o.domain || {};
|
|
438
|
-
const dMin = ( typeof dom.min === "number" ) ? dom.min : 0;
|
|
439
|
-
const dMax = ( typeof dom.max === "number" ) ? dom.max : 150;
|
|
440
|
-
const span = ( dMax - dMin ) || 1;
|
|
441
|
-
const plotTop = pad;
|
|
442
|
-
const plotBottom = height - padBottom;
|
|
443
|
-
const plotH = plotBottom - plotTop;
|
|
444
|
-
const plotLeft = padLeft;
|
|
445
|
-
const plotRight = width - pad;
|
|
446
|
-
const plotW = plotRight - plotLeft;
|
|
447
|
-
const G = Array.isArray( groups ) ? groups : [];
|
|
448
|
-
const slot = ( G.length > 0 ) ? ( plotW / G.length ) : 0;
|
|
449
|
-
const boxW = _round( slot * 0.5 );
|
|
450
|
-
|
|
451
|
-
const yFor = ( score ) => _round( plotBottom - ( ( _clamp( score, dMin, dMax ) - dMin ) / span ) * plotH );
|
|
452
|
-
|
|
453
|
-
const boxes = G.map( ( g, i ) => {
|
|
454
|
-
const cx = _round( plotLeft + ( slot * i ) + ( slot / 2 ) );
|
|
455
|
-
if ( g.suppressed ) {
|
|
456
|
-
return {
|
|
457
|
-
id: g.id,
|
|
458
|
-
label: g.label || g.id,
|
|
459
|
-
cx: cx,
|
|
460
|
-
w: boxW,
|
|
461
|
-
x: _round( cx - ( boxW / 2 ) ),
|
|
462
|
-
suppressed: true,
|
|
463
|
-
n: ( typeof g.n === "number" ) ? g.n : 0
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
return {
|
|
467
|
-
id: g.id, label: g.label || g.id, cx: cx, w: boxW, x: _round( cx - ( boxW / 2 ) ), suppressed: false,
|
|
468
|
-
n: ( typeof g.n === "number" ) ? g.n : 0,
|
|
469
|
-
min: g.min, q1: g.q1, median: g.median, q3: g.q3, max: g.max,
|
|
470
|
-
yMin: yFor( g.min ), yQ1: yFor( g.q1 ), yMed: yFor( g.median ), yQ3: yFor( g.q3 ), yMax: yFor( g.max ),
|
|
471
|
-
yExpected: ( typeof g.expected === "number" ) ? yFor( g.expected ) : null,
|
|
472
|
-
yMean: ( typeof g.mean === "number" ) ? yFor( g.mean ) : null
|
|
473
|
-
};
|
|
474
|
-
} );
|
|
475
|
-
|
|
476
|
-
const refList = Array.isArray( o.reference ) ? o.reference : [];
|
|
477
|
-
const refs = refList.map( ( ref ) => ( { v: ref.v, label: ref.label || "", y: yFor( ref.v ), x1: _round( plotLeft ), x2: _round( plotRight ) } ) );
|
|
478
|
-
|
|
479
|
-
return {
|
|
480
|
-
boxes: boxes,
|
|
481
|
-
refs: refs,
|
|
482
|
-
axis: { plotLeft: plotLeft, plotRight: plotRight, plotTop: plotTop, plotBottom: plotBottom, plotW: plotW, plotH: plotH, yFor: yFor },
|
|
483
|
-
width: width,
|
|
484
|
-
height: height
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Lays out grouped horizontal bars: every row's values share one global max (so widths are comparable across rows);
|
|
490
|
-
* each value becomes a sub-bar stacked vertically inside the row band. Pure.
|
|
491
|
-
* @param {Array<{id,label,values:Array<{key,v,tone?}>}>} rows
|
|
492
|
-
* @param {Object} [opts] {trackW=100,max?,barH=4,gap=2}
|
|
493
|
-
* @returns {{rows:Array,trackW:number,max:number}}
|
|
494
|
-
*/
|
|
495
|
-
function barsGroupedLayout( rows, opts ) {
|
|
496
|
-
const o = opts || {};
|
|
497
|
-
const trackW = ( typeof o.trackW === "number" ) ? o.trackW : 100;
|
|
498
|
-
const barH = ( typeof o.barH === "number" ) ? o.barH : 4;
|
|
499
|
-
const gap = ( typeof o.gap === "number" ) ? o.gap : 2;
|
|
500
|
-
const list = Array.isArray( rows ) ? rows : [];
|
|
501
|
-
let max = ( typeof o.max === "number" ) ? o.max : 0;
|
|
502
|
-
if ( !( typeof o.max === "number" ) ) {
|
|
503
|
-
for ( let i = 0; i < list.length; i++ ) {
|
|
504
|
-
const vals = Array.isArray( list[ i ].values ) ? list[ i ].values : [];
|
|
505
|
-
for ( let j = 0; j < vals.length; j++ ) {
|
|
506
|
-
const v = vals[ j ].v || 0;
|
|
507
|
-
if ( v > max ) {
|
|
508
|
-
max = v;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
const outRows = list.map( ( row ) => {
|
|
514
|
-
const vals = Array.isArray( row.values ) ? row.values : [];
|
|
515
|
-
const bars = vals.map( ( val, i ) => ( {
|
|
516
|
-
key: val.key, v: val.v || 0, tone: val.tone || "",
|
|
517
|
-
width: ( max > 0 ) ? _round( ( ( val.v || 0 ) / max ) * trackW ) : 0,
|
|
518
|
-
subY: _round( i * ( barH + gap ) ), height: barH
|
|
519
|
-
} ) );
|
|
520
|
-
return { id: row.id, label: row.label || row.id, bars: bars, rowHeight: _round( vals.length * ( barH + gap ) ) };
|
|
521
|
-
} );
|
|
522
|
-
return { rows: outRows, trackW: trackW, max: max };
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Lays out diverging horizontal bars centered on zero: every value shares one global max-abs; positive values
|
|
527
|
-
* extend right of centre, negative left. Pure.
|
|
528
|
-
* @param {Array<{id,label,values:Array<{key,v,tone?}>}>} rows
|
|
529
|
-
* @param {Object} [opts] {trackW=100,maxAbs?}
|
|
530
|
-
* @returns {{rows:Array,center:number,maxAbs:number}}
|
|
531
|
-
*/
|
|
532
|
-
function barsDivergingLayout( rows, opts ) {
|
|
533
|
-
const o = opts || {};
|
|
534
|
-
const trackW = ( typeof o.trackW === "number" ) ? o.trackW : 100;
|
|
535
|
-
const list = Array.isArray( rows ) ? rows : [];
|
|
536
|
-
let maxAbs = ( typeof o.maxAbs === "number" ) ? o.maxAbs : 0;
|
|
537
|
-
if ( !( typeof o.maxAbs === "number" ) ) {
|
|
538
|
-
for ( let i = 0; i < list.length; i++ ) {
|
|
539
|
-
const vals = Array.isArray( list[ i ].values ) ? list[ i ].values : [];
|
|
540
|
-
for ( let j = 0; j < vals.length; j++ ) {
|
|
541
|
-
const a = Math.abs( vals[ j ].v || 0 );
|
|
542
|
-
if ( a > maxAbs ) {
|
|
543
|
-
maxAbs = a;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
const center = _round( trackW / 2 );
|
|
549
|
-
const half = trackW / 2;
|
|
550
|
-
const outRows = list.map( ( row ) => {
|
|
551
|
-
const vals = Array.isArray( row.values ) ? row.values : [];
|
|
552
|
-
const bars = vals.map( ( val ) => {
|
|
553
|
-
const v = val.v || 0;
|
|
554
|
-
const w = ( maxAbs > 0 ) ? _round( ( Math.abs( v ) / maxAbs ) * half ) : 0;
|
|
555
|
-
const dir = ( v >= 0 ) ? "pos" : "neg";
|
|
556
|
-
const x = ( v >= 0 ) ? center : _round( center - w );
|
|
557
|
-
return { key: val.key, v: v, tone: val.tone || "", x: x, width: w, dir: dir };
|
|
558
|
-
} );
|
|
559
|
-
return { id: row.id, label: row.label || row.id, bars: bars };
|
|
560
|
-
} );
|
|
561
|
-
return { rows: outRows, center: center, maxAbs: maxAbs };
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Lays out a radar/spider chart: N axes evenly spaced (clockwise from the top), concentric polygon rings, and one
|
|
566
|
-
* polygon per series with each vertex at radius rMax·(value/axisMax) on its axis. Pure (angle math in JS).
|
|
567
|
-
* @param {Array<{id,label,max}>} axes
|
|
568
|
-
* @param {Array<{key,values:Object,tone?,style?}>} series
|
|
569
|
-
* @param {Object} [opts] {cx=50,cy=50,rMax=36,startAngle=-90,rings=[.25,.5,.75,1],labelPad=7}
|
|
570
|
-
* @returns {{cx,cy,rMax,axes:Array,rings:Array,series:Array}}
|
|
571
|
-
*/
|
|
572
|
-
function radarLayout( axes, series, opts ) {
|
|
573
|
-
const o = opts || {};
|
|
574
|
-
const cx = ( typeof o.cx === "number" ) ? o.cx : 50;
|
|
575
|
-
const cy = ( typeof o.cy === "number" ) ? o.cy : 50;
|
|
576
|
-
const rMax = ( typeof o.rMax === "number" ) ? o.rMax : 36;
|
|
577
|
-
const startAngle = ( typeof o.startAngle === "number" ) ? o.startAngle : -90;
|
|
578
|
-
const labelPad = ( typeof o.labelPad === "number" ) ? o.labelPad : 7;
|
|
579
|
-
const ringFractions = Array.isArray( o.rings ) ? o.rings : [ 0.25, 0.5, 0.75, 1 ];
|
|
580
|
-
const axisList = Array.isArray( axes ) ? axes : [];
|
|
581
|
-
const n = axisList.length;
|
|
582
|
-
const step = ( n > 0 ) ? ( 360 / n ) : 0;
|
|
583
|
-
|
|
584
|
-
const axisGeom = axisList.map( ( ax, i ) => {
|
|
585
|
-
const angle = startAngle + ( i * step );
|
|
586
|
-
const outer = _polar( cx, cy, rMax, angle );
|
|
587
|
-
const label = _polar( cx, cy, rMax + labelPad, angle );
|
|
588
|
-
return {
|
|
589
|
-
id: ax.id, label: ( ax.label !== undefined ) ? ax.label : ax.id,
|
|
590
|
-
max: ( typeof ax.max === "number" && ax.max > 0 ) ? ax.max : 1,
|
|
591
|
-
tone: ax.tone || "",
|
|
592
|
-
angle: angle, outerX: _round( outer.x ), outerY: _round( outer.y ),
|
|
593
|
-
labelX: _round( label.x ), labelY: _round( label.y )
|
|
594
|
-
};
|
|
595
|
-
} );
|
|
596
|
-
|
|
597
|
-
const rings = ringFractions.map( ( frac ) => ( {
|
|
598
|
-
frac: frac,
|
|
599
|
-
points: axisGeom.map( ( ag ) => {
|
|
600
|
-
const p = _polar( cx, cy, rMax * frac, ag.angle );
|
|
601
|
-
return _round( p.x ) + "," + _round( p.y );
|
|
602
|
-
} ).join( " " )
|
|
603
|
-
} ) );
|
|
604
|
-
|
|
605
|
-
const seriesList = Array.isArray( series ) ? series : [];
|
|
606
|
-
const laidSeries = seriesList.map( ( s ) => {
|
|
607
|
-
const dots = [];
|
|
608
|
-
const points = axisGeom.map( ( ag ) => {
|
|
609
|
-
const raw = ( s.values && typeof s.values[ ag.id ] === "number" ) ? s.values[ ag.id ] : 0;
|
|
610
|
-
const ratio = _clamp( raw / ag.max, 0, 1 );
|
|
611
|
-
const p = _polar( cx, cy, rMax * ratio, ag.angle );
|
|
612
|
-
dots.push( { x: _round( p.x ), y: _round( p.y ), axisId: ag.id, value: raw } );
|
|
613
|
-
return _round( p.x ) + "," + _round( p.y );
|
|
614
|
-
} );
|
|
615
|
-
return { key: s.key, tone: s.tone || "", style: s.style || "", points: points.join( " " ), dots: dots };
|
|
616
|
-
} );
|
|
617
|
-
|
|
618
|
-
return { cx: cx, cy: cy, rMax: rMax, axes: axisGeom, rings: rings, series: laidSeries };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Pure layout for the cross-cycle line/trend primitive (CA-X1). Maps a categorical x-axis (cycles, evenly spaced)
|
|
623
|
-
* and N series onto a viewBox. Each series may carry a {p25,p75}-style `band` (drawn as a filled area) and null
|
|
624
|
-
* gaps (the polyline breaks into separate segments — a null is NOT bridged or plotted as 0). The y-domain spans the
|
|
625
|
-
* min/max over all numeric values and band edges, unless `yMax`/`zeroBaseline` pin it.
|
|
626
|
-
*
|
|
627
|
-
* @param {Array<{key,values:Array<number|null>,band?:Array<[number,number]|null>,tone?,style?}>} series
|
|
628
|
-
* @param {Object} [opts] {width=100,height=60|28,xCount,yMax,zeroBaseline=false,sparkline=false}
|
|
629
|
-
* @returns {{W,H,padL,padR,padT,padB,innerW,innerH,n,yMin,yMax,sparkline,series:Array}}
|
|
630
|
-
*/
|
|
631
|
-
function lineLayout( series, opts ) {
|
|
632
|
-
const o = opts || {};
|
|
633
|
-
const sparkline = Boolean( o.sparkline );
|
|
634
|
-
const W = ( typeof o.width === "number" ) ? o.width : 100;
|
|
635
|
-
const H = ( typeof o.height === "number" ) ? o.height : ( sparkline ? 28 : 60 );
|
|
636
|
-
const padL = sparkline ? 1 : 12;
|
|
637
|
-
const padR = sparkline ? 1 : 4;
|
|
638
|
-
const padT = sparkline ? 2 : 4;
|
|
639
|
-
const padB = sparkline ? 2 : 10;
|
|
640
|
-
const seriesList = Array.isArray( series ) ? series : [];
|
|
641
|
-
const n = ( typeof o.xCount === "number" ) ? o.xCount
|
|
642
|
-
: ( seriesList.length && Array.isArray( seriesList[ 0 ].values ) ? seriesList[ 0 ].values.length : 0 );
|
|
643
|
-
const innerW = W - padL - padR;
|
|
644
|
-
const innerH = H - padT - padB;
|
|
645
|
-
|
|
646
|
-
let yMax = ( typeof o.yMax === "number" ) ? o.yMax : Number.NEGATIVE_INFINITY;
|
|
647
|
-
let yMin = o.zeroBaseline ? 0 : Number.POSITIVE_INFINITY;
|
|
648
|
-
for ( const s of seriesList ) {
|
|
649
|
-
const vals = Array.isArray( s.values ) ? s.values : [];
|
|
650
|
-
for ( const v of vals ) {
|
|
651
|
-
if ( typeof v === "number" ) {
|
|
652
|
-
if ( v > yMax ) {
|
|
653
|
-
yMax = v;
|
|
654
|
-
}
|
|
655
|
-
if ( !o.zeroBaseline && v < yMin ) {
|
|
656
|
-
yMin = v;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
const band = Array.isArray( s.band ) ? s.band : [];
|
|
661
|
-
for ( const b of band ) {
|
|
662
|
-
if ( b && typeof b[ 1 ] === "number" && b[ 1 ] > yMax ) {
|
|
663
|
-
yMax = b[ 1 ];
|
|
664
|
-
}
|
|
665
|
-
if ( b && !o.zeroBaseline && typeof b[ 0 ] === "number" && b[ 0 ] < yMin ) {
|
|
666
|
-
yMin = b[ 0 ];
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
if ( !isFinite( yMax ) ) {
|
|
671
|
-
yMax = o.zeroBaseline ? 1 : 0;
|
|
672
|
-
}
|
|
673
|
-
if ( !isFinite( yMin ) ) {
|
|
674
|
-
yMin = 0;
|
|
675
|
-
}
|
|
676
|
-
if ( yMax <= yMin ) {
|
|
677
|
-
yMax = yMin + 1;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const xAt = ( i ) => ( n <= 1 ) ? ( padL + ( innerW / 2 ) ) : ( padL + ( ( innerW * i ) / ( n - 1 ) ) );
|
|
681
|
-
const yAt = ( v ) => padT + ( innerH * ( 1 - ( ( v - yMin ) / ( yMax - yMin ) ) ) );
|
|
682
|
-
|
|
683
|
-
const laidSeries = seriesList.map( ( s ) => {
|
|
684
|
-
const vals = Array.isArray( s.values ) ? s.values : [];
|
|
685
|
-
const dots = [];
|
|
686
|
-
const segments = [];
|
|
687
|
-
let current = [];
|
|
688
|
-
for ( let i = 0; i < n; i++ ) {
|
|
689
|
-
const v = vals[ i ];
|
|
690
|
-
if ( typeof v === "number" ) {
|
|
691
|
-
const x = _round( xAt( i ) ), y = _round( yAt( v ) );
|
|
692
|
-
current.push( x + "," + y );
|
|
693
|
-
dots.push( { x: x, y: y, xIndex: i, value: v } );
|
|
694
|
-
} else if ( current.length ) {
|
|
695
|
-
segments.push( current.join( " " ) );
|
|
696
|
-
current = [];
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
if ( current.length ) {
|
|
700
|
-
segments.push( current.join( " " ) );
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
let band = null;
|
|
704
|
-
const bandPairs = Array.isArray( s.band ) ? s.band : [];
|
|
705
|
-
if ( bandPairs.length ) {
|
|
706
|
-
const ups = [], los = [];
|
|
707
|
-
for ( let i = 0; i < n; i++ ) {
|
|
708
|
-
const b = bandPairs[ i ];
|
|
709
|
-
if ( b && typeof b[ 0 ] === "number" && typeof b[ 1 ] === "number" ) {
|
|
710
|
-
ups.push( _round( xAt( i ) ) + "," + _round( yAt( b[ 1 ] ) ) );
|
|
711
|
-
los.unshift( _round( xAt( i ) ) + "," + _round( yAt( b[ 0 ] ) ) );
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
if ( ups.length ) {
|
|
715
|
-
band = ups.concat( los ).join( " " );
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
return { key: s.key, tone: s.tone || "", style: s.style || "", segments: segments, dots: dots, band: band };
|
|
719
|
-
} );
|
|
720
|
-
|
|
721
|
-
return {
|
|
722
|
-
W: W,
|
|
723
|
-
H: H,
|
|
724
|
-
padL: padL,
|
|
725
|
-
padR: padR,
|
|
726
|
-
padT: padT,
|
|
727
|
-
padB: padB,
|
|
728
|
-
innerW: innerW,
|
|
729
|
-
innerH: innerH,
|
|
730
|
-
n: n,
|
|
731
|
-
yMin: yMin,
|
|
732
|
-
yMax: yMax,
|
|
733
|
-
sparkline: sparkline,
|
|
734
|
-
series: laidSeries
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Attaches CSP-safe drill interactivity to an element: tabindex/role + click & Enter/Space listeners dispatching a
|
|
740
|
-
* bubbling `ti-chart:select` CustomEvent. In non-DOM environments (unit tests) it sets the a11y attributes and
|
|
741
|
-
* returns without wiring listeners.
|
|
742
|
-
* @param {Element} el
|
|
743
|
-
* @param {Object} detail
|
|
744
|
-
* @param {string} label
|
|
745
|
-
*/
|
|
746
|
-
function _attachSelect( el, detail, label ) {
|
|
747
|
-
el.setAttribute( "tabindex", "0" );
|
|
748
|
-
el.setAttribute( "role", "button" );
|
|
749
|
-
if ( label ) {
|
|
750
|
-
el.setAttribute( "aria-label", label );
|
|
751
|
-
}
|
|
752
|
-
if ( typeof el.addEventListener !== "function" || typeof CustomEvent === "undefined" ) {
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
const fire = () => {
|
|
756
|
-
el.dispatchEvent( new CustomEvent( "ti-chart:select", { detail: detail, bubbles: true } ) );
|
|
757
|
-
};
|
|
758
|
-
el.addEventListener( "click", fire );
|
|
759
|
-
el.addEventListener( "keydown", ( e ) => {
|
|
760
|
-
if ( e.key === "Enter" || e.key === " " ) {
|
|
761
|
-
e.preventDefault();
|
|
762
|
-
fire();
|
|
763
|
-
}
|
|
764
|
-
} );
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const SUPPORTED_TYPES = [ "gauge", "bars", "stat", "scatter", "heatmap", "box", "radar", "line" ]; // P0 gauge/bars/stat; 1A scatter/heatmap/box; P3 radar; P4 line
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* @typedef {Object} TiChartSpec
|
|
771
|
-
* @property {"gauge"|"bars"|"stat"|"scatter"|"heatmap"|"box"|"radar"} type P0: gauge/bars/stat; 1A: scatter/heatmap/box; P3: radar.
|
|
772
|
-
* @property {Object} data per-primitive payload (the aggregation output)
|
|
773
|
-
* @property {Object} [options] domains, sizing, labels, formatting
|
|
774
|
-
* @property {string} a11yLabel role=img label (also injected as <title>)
|
|
775
|
-
* @property {string} [a11yDesc] injected as <desc>
|
|
776
|
-
* @property {boolean} [provisional] draws the "as of now / % reporting" hatch for ACTIVE cycles
|
|
777
|
-
*/
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* Validates + fills defaults on a chart spec. Unknown/unsupported types collapse to
|
|
781
|
-
* { type:"unsupported", data:{} } so the renderer can show a graceful empty state.
|
|
782
|
-
* @param {*} spec
|
|
783
|
-
* @returns {TiChartSpec}
|
|
784
|
-
*/
|
|
785
|
-
function normalizeSpec( spec ) {
|
|
786
|
-
if ( !spec || typeof spec !== "object" ) {
|
|
787
|
-
return { type: "unsupported", data: {}, options: {}, a11yLabel: "", a11yDesc: "", provisional: false };
|
|
788
|
-
}
|
|
789
|
-
const supported = ( SUPPORTED_TYPES.indexOf( spec.type ) >= 0 );
|
|
790
|
-
return {
|
|
791
|
-
type: supported ? spec.type : "unsupported",
|
|
792
|
-
data: ( supported && spec.data && typeof spec.data === "object" ) ? spec.data : {},
|
|
793
|
-
options: ( spec.options && typeof spec.options === "object" ) ? spec.options : {},
|
|
794
|
-
a11yLabel: ( typeof spec.a11yLabel === "string" ) ? spec.a11yLabel : "",
|
|
795
|
-
a11yDesc: ( typeof spec.a11yDesc === "string" ) ? spec.a11yDesc : "",
|
|
796
|
-
provisional: Boolean( spec.provisional )
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
function _clearChildren( node ) {
|
|
801
|
-
while ( node.firstChild ) {
|
|
802
|
-
node.removeChild( node.firstChild );
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* Renders a Coverage-style gauge: a 270° track, a value arc (dashed cap when provisional),
|
|
808
|
-
* a centre value, a label, an optional sublabel ("% reporting" caveat), and optional sub-rows.
|
|
809
|
-
* All geometry from gaugeArcPath/gaugeRowsLayout.
|
|
810
|
-
* @param {Element} figure host <figure class="ti-chart">
|
|
811
|
-
* @param {TiChartSpec} spec
|
|
812
|
-
*/
|
|
813
|
-
function renderGauge( figure, spec ) {
|
|
814
|
-
const data = spec.data;
|
|
815
|
-
const value = ( typeof data.value === "number" ) ? data.value : 0;
|
|
816
|
-
const geom = { cx: 50, cy: 50, r: 42, startAngle: -225, sweep: 270 };
|
|
817
|
-
|
|
818
|
-
const svg = svgEl( "svg", { viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
819
|
-
const title = svgEl( "title", {} );
|
|
820
|
-
title.textContent = spec.a11yLabel;
|
|
821
|
-
svg.appendChild( title );
|
|
822
|
-
if ( spec.a11yDesc ) {
|
|
823
|
-
const desc = svgEl( "desc", {} );
|
|
824
|
-
desc.textContent = spec.a11yDesc;
|
|
825
|
-
svg.appendChild( desc );
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const track = svgEl( "path", { d: gaugeArcPath( 1, geom ), class: "ti-chart-gauge-track", fill: "none" } );
|
|
829
|
-
svg.appendChild( track );
|
|
830
|
-
|
|
831
|
-
const arc = svgEl( "path", { d: gaugeArcPath( value, geom ), class: "ti-chart-gauge-arc", fill: "none" } );
|
|
832
|
-
if ( spec.provisional ) {
|
|
833
|
-
arc.setAttribute( "stroke-dasharray", "4 3" );
|
|
834
|
-
}
|
|
835
|
-
svg.appendChild( arc );
|
|
836
|
-
|
|
837
|
-
const valueText = svgEl( "text", { x: 50, y: 48, class: "ti-chart-gauge-value", "text-anchor": "middle", "dominant-baseline": "central" } );
|
|
838
|
-
valueText.textContent = formatPercent( value );
|
|
839
|
-
svg.appendChild( valueText );
|
|
840
|
-
|
|
841
|
-
if ( data.label ) {
|
|
842
|
-
const labelText = svgEl( "text", { x: 50, y: 62, class: "ti-chart-gauge-label", "text-anchor": "middle" } );
|
|
843
|
-
labelText.textContent = data.label;
|
|
844
|
-
svg.appendChild( labelText );
|
|
845
|
-
}
|
|
846
|
-
if ( data.sublabel ) {
|
|
847
|
-
const subText = svgEl( "text", { x: 50, y: 71, class: "ti-chart-gauge-sublabel", "text-anchor": "middle" } );
|
|
848
|
-
subText.textContent = data.sublabel;
|
|
849
|
-
svg.appendChild( subText );
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
figure.appendChild( svg );
|
|
853
|
-
|
|
854
|
-
// a11y mirror: overall (+ the reporting caveat) + each sub-row
|
|
855
|
-
const headers = [ "Group", "Coverage" ];
|
|
856
|
-
const srRows = [ [ data.label || spec.a11yLabel, formatPercent( value ) ] ];
|
|
857
|
-
if ( data.sublabel ) {
|
|
858
|
-
srRows.push( [ "Reporting", data.sublabel ] );
|
|
859
|
-
}
|
|
860
|
-
if ( Array.isArray( data.rows ) ) {
|
|
861
|
-
const laid = gaugeRowsLayout( data.rows, { width: 100 } );
|
|
862
|
-
for ( let i = 0; i < laid.length; i++ ) {
|
|
863
|
-
srRows.push( [ laid[ i ].label, formatPercent( laid[ i ].ratio ) ] );
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
figure.appendChild( buildSrTable( headers, srRows ) );
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Appends a <title>/<desc> pair to an svg from the spec's a11y fields.
|
|
870
|
-
function _appendA11yTitle( svg, spec ) {
|
|
871
|
-
const title = svgEl( "title", {} );
|
|
872
|
-
title.textContent = spec.a11yLabel;
|
|
873
|
-
svg.appendChild( title );
|
|
874
|
-
if ( spec.a11yDesc ) {
|
|
875
|
-
const desc = svgEl( "desc", {} );
|
|
876
|
-
desc.textContent = spec.a11yDesc;
|
|
877
|
-
svg.appendChild( desc );
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* Renders bars. Dispatches on options.mode: "stacked" (default, the Phase-0 horizontal stacked segments),
|
|
883
|
-
* "grouped" (sub-bars per row sharing one global max — R2 time), "diverging" (centered on zero — R6 drivers).
|
|
884
|
-
* @param {Element} figure
|
|
885
|
-
* @param {TiChartSpec} spec
|
|
886
|
-
*/
|
|
887
|
-
function renderBars( figure, spec ) {
|
|
888
|
-
const mode = ( spec.options && spec.options.mode ) || "stacked";
|
|
889
|
-
if ( mode === "grouped" ) {
|
|
890
|
-
return _renderBarsGrouped( figure, spec );
|
|
891
|
-
}
|
|
892
|
-
if ( mode === "diverging" ) {
|
|
893
|
-
return _renderBarsDiverging( figure, spec );
|
|
894
|
-
}
|
|
895
|
-
return _renderBarsStacked( figure, spec );
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* Horizontal stacked bars. Each row is a labelled track — a caption line (row label left, optional value right)
|
|
900
|
-
* above a stack of segments laid out by barSegments; a provisional "Not started" tail is dashed/dimmed via the
|
|
901
|
-
* .ti-chart-provisional class. A swatch legend is rendered below the chart when spec.options.legend is provided.
|
|
902
|
-
*/
|
|
903
|
-
function _renderBarsStacked( figure, spec ) {
|
|
904
|
-
const data = spec.data;
|
|
905
|
-
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
906
|
-
if ( rows.length === 0 ) {
|
|
907
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
// viewBox units — not CSS pixels. Per row: a caption line (labelH) + the bar (rowH) + a gap. Bars opt out of
|
|
911
|
-
// the global svg max-height (ti-framework.css), so this scale stays identical regardless of how many rows a
|
|
912
|
-
// chart has — a short subtree chart and a tall org-wide one render at the same bar thickness.
|
|
913
|
-
const trackW = 100, rowH = 9, gap = 7, labelH = 5, padTop = 4;
|
|
914
|
-
const height = padTop + ( rows.length * ( labelH + rowH + gap ) );
|
|
915
|
-
|
|
916
|
-
const svg = svgEl( "svg", { viewBox: "0 0 100 " + _round( height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
917
|
-
_appendA11yTitle( svg, spec );
|
|
918
|
-
|
|
919
|
-
const srRows = [];
|
|
920
|
-
let cursorY = padTop;
|
|
921
|
-
for ( let r = 0; r < rows.length; r++ ) {
|
|
922
|
-
const row = rows[ r ];
|
|
923
|
-
// Caption: row label on the left, optional value (e.g. "% complete") right-aligned above the bar.
|
|
924
|
-
const capY = _round( cursorY + labelH - 2 );
|
|
925
|
-
const lbl = svgEl( "text", { x: 0, y: capY, class: "ti-chart-bar-label" } );
|
|
926
|
-
lbl.textContent = row.label || row.id || "";
|
|
927
|
-
svg.appendChild( lbl );
|
|
928
|
-
if ( row.valueLabel ) {
|
|
929
|
-
const val = svgEl( "text", { x: trackW, y: capY, "text-anchor": "end", class: "ti-chart-bar-label" } );
|
|
930
|
-
val.textContent = row.valueLabel;
|
|
931
|
-
svg.appendChild( val );
|
|
932
|
-
}
|
|
933
|
-
const barY = cursorY + labelH;
|
|
934
|
-
const segSource = row.segments || row.values || [];
|
|
935
|
-
const segs = barSegments( segSource, { width: trackW, total: row.total } );
|
|
936
|
-
for ( let s = 0; s < segs.length; s++ ) {
|
|
937
|
-
let cls = "ti-chart-bar-seg";
|
|
938
|
-
if ( segs[ s ].tone ) {
|
|
939
|
-
cls = cls + " tone-" + segs[ s ].tone;
|
|
940
|
-
}
|
|
941
|
-
if ( spec.provisional && segs[ s ].key === "Not started" ) {
|
|
942
|
-
cls = cls + " ti-chart-provisional";
|
|
943
|
-
}
|
|
944
|
-
const rect = svgEl( "rect", { x: segs[ s ].x, y: barY, width: segs[ s ].width, height: rowH, rx: 2, class: cls } );
|
|
945
|
-
svg.appendChild( rect );
|
|
946
|
-
srRows.push( [ row.label || row.id, segs[ s ].key, String( segSource[ s ].v || 0 ) ] );
|
|
947
|
-
}
|
|
948
|
-
cursorY = barY + rowH + gap;
|
|
949
|
-
}
|
|
950
|
-
figure.appendChild( svg );
|
|
951
|
-
if ( spec.options && Array.isArray( spec.options.legend ) && spec.options.legend.length > 0 ) {
|
|
952
|
-
figure.appendChild( _buildChartLegend( spec.options.legend ) );
|
|
953
|
-
}
|
|
954
|
-
figure.appendChild( buildSrTable( [ "Row", "Segment", "Count" ], srRows ) );
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
/**
|
|
958
|
-
* Builds a visible HTML swatch legend appended below a chart. Each entry is { label, tone, dashed? }; the swatch
|
|
959
|
-
* colour routes through the same grade/series tokens as the chart segments via a tone-* class (so it re-themes in
|
|
960
|
-
* step), and a `dashed:true` entry gets an outlined swatch (.is-dashed) to denote a dashed series (e.g. expected).
|
|
961
|
-
*
|
|
962
|
-
* @param {Array<{label:string,tone:string,dashed?:boolean}>} items
|
|
963
|
-
* @returns {HTMLElement}
|
|
964
|
-
*/
|
|
965
|
-
function _buildChartLegend( items ) {
|
|
966
|
-
const wrap = document.createElement( "div" );
|
|
967
|
-
wrap.className = "ti-chart-legend";
|
|
968
|
-
for ( let i = 0; i < items.length; i++ ) {
|
|
969
|
-
const item = document.createElement( "span" );
|
|
970
|
-
item.className = "ti-chart-legend-item";
|
|
971
|
-
const swatch = document.createElement( "span" );
|
|
972
|
-
swatch.className = "ti-chart-legend-swatch" + ( items[ i ].tone ? ( " tone-" + items[ i ].tone ) : "" ) + ( items[ i ].dashed ? " is-dashed" : "" );
|
|
973
|
-
item.appendChild( swatch );
|
|
974
|
-
const text = document.createElement( "span" );
|
|
975
|
-
text.textContent = items[ i ].label || "";
|
|
976
|
-
item.appendChild( text );
|
|
977
|
-
wrap.appendChild( item );
|
|
978
|
-
}
|
|
979
|
-
return wrap;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Grouped horizontal bars: per row, one sub-bar per value, all widths on one shared global max so rows compare.
|
|
984
|
-
* A swatch legend is rendered below the chart when spec.options.legend is provided, and a per-bar value caption
|
|
985
|
-
* (formatNumber, 2dp) is drawn at each bar's end when spec.options.valueLabels is set.
|
|
986
|
-
*/
|
|
987
|
-
function _renderBarsGrouped( figure, spec ) {
|
|
988
|
-
const data = spec.data;
|
|
989
|
-
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
990
|
-
if ( rows.length === 0 ) {
|
|
991
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
const valueLabels = !!( spec.options && spec.options.valueLabels );
|
|
995
|
-
// Optional bar height + value-caption font (viewBox units) — default to the layout's own 4 / the CSS size.
|
|
996
|
-
const barThickness = ( spec.options && typeof spec.options.barThickness === "number" ) ? spec.options.barThickness : null;
|
|
997
|
-
const valueFontSize = ( spec.options && typeof spec.options.valueFontSize === "number" ) ? spec.options.valueFontSize : null;
|
|
998
|
-
// Reserve a right gutter for the value caption so it never spills past the 100-wide viewBox.
|
|
999
|
-
const trackW = valueLabels ? 86 : 100, rowGap = 6, labelH = 5, padTop = 4;
|
|
1000
|
-
const layoutOpts = { trackW: trackW };
|
|
1001
|
-
if ( barThickness !== null ) { layoutOpts.barH = barThickness; }
|
|
1002
|
-
const layout = barsGroupedLayout( rows, layoutOpts );
|
|
1003
|
-
let height = padTop;
|
|
1004
|
-
for ( let i = 0; i < layout.rows.length; i++ ) {
|
|
1005
|
-
height += labelH + layout.rows[ i ].rowHeight + rowGap;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
const svg = svgEl( "svg", { viewBox: "0 0 100 " + _round( height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1009
|
-
_appendA11yTitle( svg, spec );
|
|
1010
|
-
|
|
1011
|
-
const srRows = [];
|
|
1012
|
-
let cursorY = padTop;
|
|
1013
|
-
for ( let r = 0; r < layout.rows.length; r++ ) {
|
|
1014
|
-
const row = layout.rows[ r ];
|
|
1015
|
-
const lbl = svgEl( "text", { x: 0, y: _round( cursorY + 3.5 ), class: "ti-chart-bar-label" } );
|
|
1016
|
-
lbl.textContent = row.label;
|
|
1017
|
-
svg.appendChild( lbl );
|
|
1018
|
-
const barsTop = cursorY + labelH;
|
|
1019
|
-
for ( let b = 0; b < row.bars.length; b++ ) {
|
|
1020
|
-
const bar = row.bars[ b ];
|
|
1021
|
-
let cls = "ti-chart-bar-seg";
|
|
1022
|
-
if ( bar.tone ) {
|
|
1023
|
-
cls = cls + " tone-" + bar.tone;
|
|
1024
|
-
}
|
|
1025
|
-
const rect = svgEl( "rect", { x: 0, y: _round( barsTop + bar.subY ), width: bar.width, height: bar.height, rx: 1, class: cls } );
|
|
1026
|
-
if ( spec.provisional ) {
|
|
1027
|
-
rect.setAttribute( "opacity", "0.7" );
|
|
1028
|
-
}
|
|
1029
|
-
svg.appendChild( rect );
|
|
1030
|
-
if ( valueLabels ) {
|
|
1031
|
-
// The caption uses .ti-chart-bar-value (fill only — NO font-size in CSS), so the font-size
|
|
1032
|
-
// presentation attribute actually governs; a CSS font-size rule (e.g. .ti-chart-bar-label) would
|
|
1033
|
-
// otherwise win over it. Default 4 matches the prior caption size.
|
|
1034
|
-
const vt = svgEl( "text", {
|
|
1035
|
-
x: _round( bar.width + 1.5 ),
|
|
1036
|
-
y: _round( barsTop + bar.subY + ( bar.height / 2 ) ),
|
|
1037
|
-
class: "ti-chart-bar-value",
|
|
1038
|
-
"font-size": ( valueFontSize !== null ) ? valueFontSize : 4,
|
|
1039
|
-
"dominant-baseline": "central"
|
|
1040
|
-
} );
|
|
1041
|
-
vt.textContent = formatNumber( bar.v, 2 );
|
|
1042
|
-
svg.appendChild( vt );
|
|
1043
|
-
}
|
|
1044
|
-
srRows.push( [ row.label, bar.key, String( bar.v ) ] );
|
|
1045
|
-
}
|
|
1046
|
-
cursorY = barsTop + row.rowHeight + rowGap;
|
|
1047
|
-
}
|
|
1048
|
-
figure.appendChild( svg );
|
|
1049
|
-
if ( spec.options && Array.isArray( spec.options.legend ) && spec.options.legend.length > 0 ) {
|
|
1050
|
-
figure.appendChild( _buildChartLegend( spec.options.legend ) );
|
|
1051
|
-
}
|
|
1052
|
-
figure.appendChild( buildSrTable( [ "Row", "Series", "Value" ], srRows ) );
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Diverging horizontal bars centered on zero: per row, one bar per signed value; positive extends right, negative
|
|
1057
|
-
* left, on one shared max-abs. A center axis line marks zero.
|
|
1058
|
-
*/
|
|
1059
|
-
function _renderBarsDiverging( figure, spec ) {
|
|
1060
|
-
const data = spec.data;
|
|
1061
|
-
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
1062
|
-
if ( rows.length === 0 ) {
|
|
1063
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1066
|
-
// Landscape viewBox (width 200) so a 9-row diverging chart reads wide, not portrait; the bar math scales to trackW.
|
|
1067
|
-
const trackW = 200, rowH = 8, gap = 3, labelH = 5, padTop = 4;
|
|
1068
|
-
const layout = barsDivergingLayout( rows, { trackW: trackW } );
|
|
1069
|
-
const bandH = labelH + rowH + gap;
|
|
1070
|
-
const height = padTop + ( layout.rows.length * bandH );
|
|
1071
|
-
|
|
1072
|
-
const svg = svgEl( "svg", { viewBox: "0 0 200 " + _round( height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1073
|
-
_appendA11yTitle( svg, spec );
|
|
1074
|
-
svg.appendChild( svgEl( "line", { x1: layout.center, y1: padTop, x2: layout.center, y2: _round( height ), class: "ti-chart-bar-axis" } ) );
|
|
1075
|
-
|
|
1076
|
-
const srRows = [];
|
|
1077
|
-
for ( let r = 0; r < layout.rows.length; r++ ) {
|
|
1078
|
-
const row = layout.rows[ r ];
|
|
1079
|
-
const bandTop = padTop + ( r * bandH );
|
|
1080
|
-
const lbl = svgEl( "text", { x: 0, y: _round( bandTop + 3.5 ), class: "ti-chart-bar-label" } );
|
|
1081
|
-
lbl.textContent = row.label;
|
|
1082
|
-
svg.appendChild( lbl );
|
|
1083
|
-
const barsTop = bandTop + labelH;
|
|
1084
|
-
const sub = Math.max( 1, row.bars.length );
|
|
1085
|
-
const subH = rowH / sub;
|
|
1086
|
-
for ( let b = 0; b < row.bars.length; b++ ) {
|
|
1087
|
-
const bar = row.bars[ b ];
|
|
1088
|
-
let cls = "ti-chart-bar-seg";
|
|
1089
|
-
if ( bar.tone ) {
|
|
1090
|
-
cls = cls + " tone-" + bar.tone;
|
|
1091
|
-
}
|
|
1092
|
-
const rect = svgEl( "rect", {
|
|
1093
|
-
x: bar.x,
|
|
1094
|
-
y: _round( barsTop + ( b * subH ) ),
|
|
1095
|
-
width: bar.width,
|
|
1096
|
-
height: _round( subH * 0.9 ),
|
|
1097
|
-
rx: 0.5,
|
|
1098
|
-
class: cls
|
|
1099
|
-
} );
|
|
1100
|
-
if ( spec.provisional ) {
|
|
1101
|
-
rect.setAttribute( "opacity", "0.7" );
|
|
1102
|
-
}
|
|
1103
|
-
svg.appendChild( rect );
|
|
1104
|
-
srRows.push( [ row.label, bar.key, String( bar.v ) ] );
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
figure.appendChild( svg );
|
|
1108
|
-
figure.appendChild( buildSrTable( [ "Row", "Series", "Gap" ], srRows ) );
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/**
|
|
1112
|
-
* Renders a KPI stat tile (HTML+SVG-free). value/label/sub plus an optional pct mini-bar whose
|
|
1113
|
-
* fill width rides as a CSS var via setProperty (the sanctioned style exception).
|
|
1114
|
-
* @param {Element} figure
|
|
1115
|
-
* @param {TiChartSpec} spec
|
|
1116
|
-
*/
|
|
1117
|
-
function renderStat( figure, spec ) {
|
|
1118
|
-
const data = spec.data;
|
|
1119
|
-
const doc = ( typeof document !== "undefined" ) ? document : null;
|
|
1120
|
-
const wrap = doc.createElement( "div" );
|
|
1121
|
-
wrap.setAttribute( "class", "ti-chart-stat" );
|
|
1122
|
-
const valueEl = doc.createElement( "div" );
|
|
1123
|
-
valueEl.setAttribute( "class", "ti-chart-stat-value tabular-nums" );
|
|
1124
|
-
const hasValue = ( typeof data.value === "number" && Number.isFinite( data.value ) );
|
|
1125
|
-
valueEl.textContent = hasValue ? formatNumber( data.value ) : "—";
|
|
1126
|
-
wrap.appendChild( valueEl );
|
|
1127
|
-
if ( data.label ) {
|
|
1128
|
-
const l = doc.createElement( "div" );
|
|
1129
|
-
l.setAttribute( "class", "ti-chart-stat-label" );
|
|
1130
|
-
l.textContent = data.label;
|
|
1131
|
-
wrap.appendChild( l );
|
|
1132
|
-
}
|
|
1133
|
-
if ( data.sub ) {
|
|
1134
|
-
const sub = doc.createElement( "div" );
|
|
1135
|
-
sub.setAttribute( "class", "ti-chart-stat-sub" );
|
|
1136
|
-
sub.textContent = data.sub;
|
|
1137
|
-
wrap.appendChild( sub );
|
|
1138
|
-
}
|
|
1139
|
-
if ( typeof data.pct === "number" ) {
|
|
1140
|
-
const bar = doc.createElement( "div" );
|
|
1141
|
-
bar.setAttribute( "class", "ti-chart-stat-bar" );
|
|
1142
|
-
const fill = doc.createElement( "div" );
|
|
1143
|
-
fill.setAttribute( "class", "ti-chart-stat-fill" );
|
|
1144
|
-
fill.style.setProperty( "--pct", formatPercent( data.pct ) ); // sanctioned --var exception
|
|
1145
|
-
bar.appendChild( fill );
|
|
1146
|
-
wrap.appendChild( bar );
|
|
1147
|
-
}
|
|
1148
|
-
figure.appendChild( wrap );
|
|
1149
|
-
figure.appendChild( buildSrTable( [ "Metric", "Value" ], [ [ data.label || spec.a11yLabel, ( hasValue ? formatNumber( data.value ) : "—" ) ] ] ) );
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
/**
|
|
1153
|
-
* Renders a scatter plot (R3 alignment quadrant): optional quadrant midlines + y=x diagonal, then one circle per
|
|
1154
|
-
* point (radius from z when options.bubble==="z"). Points are drill-interactive unless options.anonymize.
|
|
1155
|
-
* @param {Element} figure
|
|
1156
|
-
* @param {TiChartSpec} spec
|
|
1157
|
-
*/
|
|
1158
|
-
function renderScatter( figure, spec ) {
|
|
1159
|
-
const data = spec.data;
|
|
1160
|
-
const points = Array.isArray( data.points ) ? data.points : [];
|
|
1161
|
-
const opts = Object.assign( {}, spec.options, { diagonal: !!data.diagonal } );
|
|
1162
|
-
const anonymize = !!( spec.options && spec.options.anonymize );
|
|
1163
|
-
const layout = scatterLayout( points, opts );
|
|
1164
|
-
|
|
1165
|
-
const svg = svgEl( "svg", { viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1166
|
-
_appendA11yTitle( svg, spec );
|
|
1167
|
-
|
|
1168
|
-
if ( layout.midX ) {
|
|
1169
|
-
svg.appendChild( svgEl( "line", { x1: layout.midX.x, y1: layout.midX.y1, x2: layout.midX.x, y2: layout.midX.y2, class: "ti-chart-scatter-mid" } ) );
|
|
1170
|
-
}
|
|
1171
|
-
if ( layout.midY ) {
|
|
1172
|
-
svg.appendChild( svgEl( "line", { x1: layout.midY.x1, y1: layout.midY.y, x2: layout.midY.x2, y2: layout.midY.y, class: "ti-chart-scatter-mid" } ) );
|
|
1173
|
-
}
|
|
1174
|
-
if ( layout.diagonal ) {
|
|
1175
|
-
svg.appendChild( svgEl( "line", {
|
|
1176
|
-
x1: layout.diagonal.x1,
|
|
1177
|
-
y1: layout.diagonal.y1,
|
|
1178
|
-
x2: layout.diagonal.x2,
|
|
1179
|
-
y2: layout.diagonal.y2,
|
|
1180
|
-
class: "ti-chart-scatter-diag"
|
|
1181
|
-
} ) );
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
const srRows = [];
|
|
1185
|
-
for ( let i = 0; i < layout.points.length; i++ ) {
|
|
1186
|
-
const p = layout.points[ i ];
|
|
1187
|
-
let cls = "ti-chart-scatter-pt";
|
|
1188
|
-
if ( p.tone ) {
|
|
1189
|
-
cls = cls + " tone-" + p.tone;
|
|
1190
|
-
}
|
|
1191
|
-
if ( spec.provisional ) {
|
|
1192
|
-
cls = cls + " ti-chart-provisional";
|
|
1193
|
-
}
|
|
1194
|
-
const circle = svgEl( "circle", { cx: p.cx, cy: p.cy, r: p.r, class: cls } );
|
|
1195
|
-
if ( !anonymize ) {
|
|
1196
|
-
_attachSelect( circle, { id: p.id, label: p.label } );
|
|
1197
|
-
if ( p.label ) {
|
|
1198
|
-
const t = svgEl( "title", {} );
|
|
1199
|
-
t.textContent = String( p.label );
|
|
1200
|
-
circle.appendChild( t );
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
svg.appendChild( circle );
|
|
1204
|
-
srRows.push( [ anonymize ? "" : ( ( p.label !== null && p.label !== undefined ) ? String( p.label ) : String( p.id || "" ) ), formatNumber( p.x, 2 ), formatNumber( p.y, 2 ), ( p.z !== null ) ? formatNumber( p.z, 2 ) : "—" ] );
|
|
1205
|
-
}
|
|
1206
|
-
figure.appendChild( svg );
|
|
1207
|
-
figure.appendChild( buildSrTable( [ "Point", "Manager", "Self", "Team" ], srRows ) );
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
/**
|
|
1211
|
-
* Renders a heatmap (R4): a grid of cells colored by a sequential quantile ramp (cell-q1..5) or a diverging
|
|
1212
|
-
* grade scale (cell-pos/neg with magnitude as the opacity presentation attribute), plus row/column labels.
|
|
1213
|
-
* @param {Element} figure
|
|
1214
|
-
* @param {TiChartSpec} spec
|
|
1215
|
-
*/
|
|
1216
|
-
function renderHeatmap( figure, spec ) {
|
|
1217
|
-
const data = spec.data;
|
|
1218
|
-
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
1219
|
-
const cols = Array.isArray( data.cols ) ? data.cols : [];
|
|
1220
|
-
const cells = Array.isArray( data.cells ) ? data.cells : [];
|
|
1221
|
-
if ( rows.length === 0 || cols.length === 0 ) {
|
|
1222
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
const scale = ( spec.options && spec.options.scale === "diverging" ) ? "diverging" : "sequential";
|
|
1226
|
-
const layout = heatmapLayout( rows, cols, cells, Object.assign( {}, spec.options, { scale: scale } ) );
|
|
1227
|
-
|
|
1228
|
-
const svg = svgEl( "svg", { viewBox: "0 0 100 " + _round( layout.height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1229
|
-
_appendA11yTitle( svg, spec );
|
|
1230
|
-
|
|
1231
|
-
const _heatRowLabels = rows.map( ( r ) => r.label || r.id );
|
|
1232
|
-
const _heatColLabels = cols.map( ( c ) => c.label || c.id );
|
|
1233
|
-
for ( let i = 0; i < layout.cells.length; i++ ) {
|
|
1234
|
-
const cell = layout.cells[ i ];
|
|
1235
|
-
let cls = "ti-chart-heat-cell";
|
|
1236
|
-
if ( cell.suppressed ) {
|
|
1237
|
-
cls = cls + " suppressed";
|
|
1238
|
-
} else if ( scale === "sequential" ) {
|
|
1239
|
-
cls = cls + " cell-q" + cell.bucket;
|
|
1240
|
-
} else {
|
|
1241
|
-
cls = cls + " cell-" + cell.sign;
|
|
1242
|
-
}
|
|
1243
|
-
const rect = svgEl( "rect", { x: cell.x, y: cell.y, width: cell.w, height: cell.h, class: cls } );
|
|
1244
|
-
if ( scale === "diverging" && !cell.suppressed && cell.sign !== "zero" ) {
|
|
1245
|
-
rect.setAttribute( "opacity", String( _round( 0.25 + ( 0.75 * cell.mag ) ) ) );
|
|
1246
|
-
}
|
|
1247
|
-
if ( !cell.suppressed ) {
|
|
1248
|
-
const cellV = ( scale === "diverging" ) ? formatNumber( cell.delta, 2 ) : formatNumber( cell.v, 2 );
|
|
1249
|
-
const cellLabel = String( _heatRowLabels[ cell.r ] || cell.r ) + " / " + String( _heatColLabels[ cell.c ] || cell.c ) + ": " + cellV;
|
|
1250
|
-
_attachSelect( rect, { r: cell.r, c: cell.c }, cellLabel );
|
|
1251
|
-
}
|
|
1252
|
-
svg.appendChild( rect );
|
|
1253
|
-
}
|
|
1254
|
-
for ( let i = 0; i < layout.rowLabels.length; i++ ) {
|
|
1255
|
-
const rl = layout.rowLabels[ i ];
|
|
1256
|
-
const t = svgEl( "text", { x: rl.x, y: rl.y, class: "ti-chart-heat-label", "text-anchor": "end", "dominant-baseline": "central" } );
|
|
1257
|
-
t.textContent = String( rl.label );
|
|
1258
|
-
svg.appendChild( t );
|
|
1259
|
-
}
|
|
1260
|
-
for ( let i = 0; i < layout.colLabels.length; i++ ) {
|
|
1261
|
-
const cl = layout.colLabels[ i ];
|
|
1262
|
-
const t = svgEl( "text", { x: cl.x, y: cl.y, class: "ti-chart-heat-label", "text-anchor": "middle" } );
|
|
1263
|
-
t.textContent = String( cl.label );
|
|
1264
|
-
svg.appendChild( t );
|
|
1265
|
-
}
|
|
1266
|
-
figure.appendChild( svg );
|
|
1267
|
-
|
|
1268
|
-
const srRows = [];
|
|
1269
|
-
const colByIndex = cols.map( ( c ) => c.label || c.id );
|
|
1270
|
-
const rowByIndex = rows.map( ( r ) => r.label || r.id );
|
|
1271
|
-
for ( let i = 0; i < layout.cells.length; i++ ) {
|
|
1272
|
-
const cell = layout.cells[ i ];
|
|
1273
|
-
const v = cell.suppressed ? "n<min" : ( ( scale === "diverging" ) ? formatNumber( cell.delta, 2 ) : formatNumber( cell.v, 2 ) );
|
|
1274
|
-
srRows.push( [ String( rowByIndex[ cell.r ] || cell.r ), String( colByIndex[ cell.c ] || cell.c ), v ] );
|
|
1275
|
-
}
|
|
1276
|
-
figure.appendChild( buildSrTable( [ "Row", "Column", ( scale === "diverging" ) ? "Gap" : "Value" ], srRows ) );
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
/**
|
|
1280
|
-
* Renders box-plots (R5 level correlation): one box per group (q1..q3 with median line + min/max whiskers), an
|
|
1281
|
-
* optional dashed expected marker and mean dot, plus global reference line(s).
|
|
1282
|
-
* @param {Element} figure
|
|
1283
|
-
* @param {TiChartSpec} spec
|
|
1284
|
-
*/
|
|
1285
|
-
function renderBox( figure, spec ) {
|
|
1286
|
-
const data = spec.data;
|
|
1287
|
-
const groups = Array.isArray( data.groups ) ? data.groups : [];
|
|
1288
|
-
if ( groups.length === 0 ) {
|
|
1289
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
const layout = boxLayout( groups, Object.assign( { reference: data.reference }, spec.options ) );
|
|
1293
|
-
|
|
1294
|
-
const svg = svgEl( "svg", {
|
|
1295
|
-
viewBox: "0 0 " + _round( layout.width ) + " " + _round( layout.height ),
|
|
1296
|
-
preserveAspectRatio: "xMidYMid meet",
|
|
1297
|
-
role: "img"
|
|
1298
|
-
} );
|
|
1299
|
-
_appendA11yTitle( svg, spec );
|
|
1300
|
-
|
|
1301
|
-
// global reference lines (e.g. T3)
|
|
1302
|
-
for ( let i = 0; i < layout.refs.length; i++ ) {
|
|
1303
|
-
const ref = layout.refs[ i ];
|
|
1304
|
-
svg.appendChild( svgEl( "line", { x1: ref.x1, y1: ref.y, x2: ref.x2, y2: ref.y, class: "ti-chart-box-ref" } ) );
|
|
1305
|
-
if ( ref.label ) {
|
|
1306
|
-
const t = svgEl( "text", { x: ref.x2, y: _round( ref.y - 0.5 ), class: "ti-chart-box-ref-label", "text-anchor": "end" } );
|
|
1307
|
-
t.textContent = String( ref.label );
|
|
1308
|
-
svg.appendChild( t );
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
const srRows = [];
|
|
1313
|
-
for ( let i = 0; i < layout.boxes.length; i++ ) {
|
|
1314
|
-
const box = layout.boxes[ i ];
|
|
1315
|
-
const lbl = svgEl( "text", { x: box.cx, y: _round( layout.axis.plotBottom + 6 ), class: "ti-chart-box-label", "text-anchor": "middle" } );
|
|
1316
|
-
lbl.textContent = String( box.label );
|
|
1317
|
-
svg.appendChild( lbl );
|
|
1318
|
-
if ( box.suppressed ) {
|
|
1319
|
-
srRows.push( [ String( box.label ), "n<min", "", "", "", "", String( box.n ) ] );
|
|
1320
|
-
continue;
|
|
1321
|
-
}
|
|
1322
|
-
// whisker (min..max) + caps
|
|
1323
|
-
svg.appendChild( svgEl( "line", { x1: box.cx, y1: box.yMax, x2: box.cx, y2: box.yMin, class: "ti-chart-box-whisker" } ) );
|
|
1324
|
-
svg.appendChild( svgEl( "line", {
|
|
1325
|
-
x1: _round( box.cx - ( box.w / 3 ) ),
|
|
1326
|
-
y1: box.yMax,
|
|
1327
|
-
x2: _round( box.cx + ( box.w / 3 ) ),
|
|
1328
|
-
y2: box.yMax,
|
|
1329
|
-
class: "ti-chart-box-cap"
|
|
1330
|
-
} ) );
|
|
1331
|
-
svg.appendChild( svgEl( "line", {
|
|
1332
|
-
x1: _round( box.cx - ( box.w / 3 ) ),
|
|
1333
|
-
y1: box.yMin,
|
|
1334
|
-
x2: _round( box.cx + ( box.w / 3 ) ),
|
|
1335
|
-
y2: box.yMin,
|
|
1336
|
-
class: "ti-chart-box-cap"
|
|
1337
|
-
} ) );
|
|
1338
|
-
// box (q1..q3) — note yQ3 (higher score) is the smaller pixel, so it is the top
|
|
1339
|
-
const boxRect = svgEl( "rect", { x: box.x, y: box.yQ3, width: box.w, height: _round( box.yQ1 - box.yQ3 ), class: "ti-chart-box-box" } );
|
|
1340
|
-
const boxLabel = String( box.label ) + ": " + formatNumber( box.q1 ) + "–" + formatNumber( box.q3 ) + " med " + formatNumber( box.median );
|
|
1341
|
-
_attachSelect( boxRect, { id: box.id }, boxLabel );
|
|
1342
|
-
svg.appendChild( boxRect );
|
|
1343
|
-
svg.appendChild( svgEl( "line", { x1: box.x, y1: box.yMed, x2: _round( box.x + box.w ), y2: box.yMed, class: "ti-chart-box-median" } ) );
|
|
1344
|
-
if ( box.yExpected !== null ) {
|
|
1345
|
-
svg.appendChild( svgEl( "line", {
|
|
1346
|
-
x1: _round( box.cx - ( box.w / 2 ) ),
|
|
1347
|
-
y1: box.yExpected,
|
|
1348
|
-
x2: _round( box.cx + ( box.w / 2 ) ),
|
|
1349
|
-
y2: box.yExpected,
|
|
1350
|
-
class: "ti-chart-box-expected"
|
|
1351
|
-
} ) );
|
|
1352
|
-
}
|
|
1353
|
-
if ( box.yMean !== null ) {
|
|
1354
|
-
svg.appendChild( svgEl( "circle", { cx: box.cx, cy: box.yMean, r: 0.9, class: "ti-chart-box-mean" } ) );
|
|
1355
|
-
}
|
|
1356
|
-
srRows.push( [ String( box.label ), formatNumber( box.min ), formatNumber( box.q1 ), formatNumber( box.median ), formatNumber( box.q3 ), formatNumber( box.max ), String( box.n ) ] );
|
|
1357
|
-
}
|
|
1358
|
-
figure.appendChild( svg );
|
|
1359
|
-
figure.appendChild( buildSrTable( [ "Level", "Min", "Q1", "Median", "Q3", "Max", "N" ], srRows ) );
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
/**
|
|
1363
|
-
* Renders a radar/spider chart (the individual 9-subcategory profile): concentric polygon rings, axis spokes +
|
|
1364
|
-
* labels, one filled polygon per series (self/manager/team), and an optional dashed "expected" series. All geometry
|
|
1365
|
-
* via radarLayout + setAttribute; an "expected"/provisional series draws unfilled with stroke-dasharray.
|
|
1366
|
-
* @param {Element} figure
|
|
1367
|
-
* @param {TiChartSpec} spec
|
|
1368
|
-
*/
|
|
1369
|
-
function renderRadar( figure, spec ) {
|
|
1370
|
-
const data = spec.data;
|
|
1371
|
-
const axes = Array.isArray( data.axes ) ? data.axes : [];
|
|
1372
|
-
const series = Array.isArray( data.series ) ? data.series : [];
|
|
1373
|
-
if ( axes.length === 0 ) {
|
|
1374
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
const layout = radarLayout( axes, series, spec.options || {} );
|
|
1378
|
-
|
|
1379
|
-
const svg = svgEl( "svg", { viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1380
|
-
_appendA11yTitle( svg, spec );
|
|
1381
|
-
|
|
1382
|
-
for ( let i = 0; i < layout.rings.length; i++ ) {
|
|
1383
|
-
svg.appendChild( svgEl( "polygon", { points: layout.rings[ i ].points, class: "ti-chart-radar-ring", fill: "none" } ) );
|
|
1384
|
-
}
|
|
1385
|
-
for ( let i = 0; i < layout.axes.length; i++ ) {
|
|
1386
|
-
const ax = layout.axes[ i ];
|
|
1387
|
-
svg.appendChild( svgEl( "line", { x1: layout.cx, y1: layout.cy, x2: ax.outerX, y2: ax.outerY, class: "ti-chart-radar-spoke" } ) );
|
|
1388
|
-
const t = svgEl( "text", {
|
|
1389
|
-
x: ax.labelX,
|
|
1390
|
-
y: ax.labelY,
|
|
1391
|
-
class: "ti-chart-radar-axis-label" + ( ax.tone ? ( " tone-" + ax.tone ) : "" ),
|
|
1392
|
-
"text-anchor": "middle",
|
|
1393
|
-
"dominant-baseline": "central"
|
|
1394
|
-
} );
|
|
1395
|
-
t.textContent = String( ax.label );
|
|
1396
|
-
svg.appendChild( t );
|
|
1397
|
-
}
|
|
1398
|
-
for ( let i = 0; i < layout.series.length; i++ ) {
|
|
1399
|
-
const s = layout.series[ i ];
|
|
1400
|
-
let cls = "ti-chart-radar-poly";
|
|
1401
|
-
if ( s.tone ) {
|
|
1402
|
-
cls = cls + " tone-" + s.tone;
|
|
1403
|
-
}
|
|
1404
|
-
const poly = svgEl( "polygon", { points: s.points, class: cls } );
|
|
1405
|
-
if ( s.style === "dashed" || spec.provisional ) {
|
|
1406
|
-
poly.setAttribute( "fill", "none" );
|
|
1407
|
-
poly.setAttribute( "stroke-dasharray", "3 2" );
|
|
1408
|
-
}
|
|
1409
|
-
svg.appendChild( poly );
|
|
1410
|
-
for ( let d = 0; d < s.dots.length; d++ ) {
|
|
1411
|
-
let dotCls = "ti-chart-radar-dot";
|
|
1412
|
-
if ( s.tone ) {
|
|
1413
|
-
dotCls = dotCls + " tone-" + s.tone;
|
|
1414
|
-
}
|
|
1415
|
-
svg.appendChild( svgEl( "circle", { cx: s.dots[ d ].x, cy: s.dots[ d ].y, r: 0.9, class: dotCls } ) );
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
figure.appendChild( svg );
|
|
1419
|
-
if ( spec.options && Array.isArray( spec.options.legend ) && spec.options.legend.length > 0 ) {
|
|
1420
|
-
figure.appendChild( _buildChartLegend( spec.options.legend ) );
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
const headers = [ "Axis" ].concat( layout.series.map( ( s ) => s.key ) );
|
|
1424
|
-
const srRows = layout.axes.map( ( ax ) => {
|
|
1425
|
-
const row = [ String( ax.label ) ];
|
|
1426
|
-
for ( let i = 0; i < layout.series.length; i++ ) {
|
|
1427
|
-
const dot = layout.series[ i ].dots.find( ( d ) => d.axisId === ax.id );
|
|
1428
|
-
row.push( dot ? formatNumber( dot.value, 2 ) : "—" );
|
|
1429
|
-
}
|
|
1430
|
-
return row;
|
|
1431
|
-
} );
|
|
1432
|
-
figure.appendChild( buildSrTable( headers, srRows ) );
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
/**
|
|
1436
|
-
* Renders the cross-cycle line/trend primitive (CA-X1): an optional baseline axis, one filled band area per series
|
|
1437
|
-
* (p25–p75), a polyline per contiguous segment (null gaps break the line), vertex dots, x-axis cycle labels, and an
|
|
1438
|
-
* sr-table. A `style:"dashed"` series (or spec.provisional) draws dashed; `options.provisionalLastPoint` dashes just
|
|
1439
|
-
* the final connector of the primary series (the live ACTIVE cycle still in flight) and marks its last dot. Sparkline
|
|
1440
|
-
* mode drops the axis/labels. All geometry via lineLayout + setAttribute (no element.style).
|
|
1441
|
-
* @param {Element} figure
|
|
1442
|
-
* @param {TiChartSpec} spec
|
|
1443
|
-
*/
|
|
1444
|
-
function renderLine( figure, spec ) {
|
|
1445
|
-
const data = spec.data;
|
|
1446
|
-
const x = Array.isArray( data.x ) ? data.x : [];
|
|
1447
|
-
const series = Array.isArray( data.series ) ? data.series : [];
|
|
1448
|
-
if ( x.length === 0 || series.length === 0 ) {
|
|
1449
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1450
|
-
return;
|
|
1451
|
-
}
|
|
1452
|
-
const options = spec.options || {};
|
|
1453
|
-
const layout = lineLayout( series, Object.assign( {}, options, { xCount: x.length } ) );
|
|
1454
|
-
|
|
1455
|
-
const svg = svgEl( "svg", { viewBox: "0 0 " + _round( layout.W ) + " " + _round( layout.H ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1456
|
-
_appendA11yTitle( svg, spec );
|
|
1457
|
-
|
|
1458
|
-
if ( !layout.sparkline ) {
|
|
1459
|
-
const axisY = _round( layout.padT + layout.innerH );
|
|
1460
|
-
svg.appendChild( svgEl( "line", { x1: layout.padL, y1: axisY, x2: _round( layout.W - layout.padR ), y2: axisY, class: "ti-chart-line-axis" } ) );
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
const provisionalLast = Boolean( options.provisionalLastPoint );
|
|
1464
|
-
|
|
1465
|
-
for ( let i = 0; i < layout.series.length; i++ ) {
|
|
1466
|
-
const s = layout.series[ i ];
|
|
1467
|
-
const tone = s.tone ? ( " tone-" + s.tone ) : "";
|
|
1468
|
-
const dashed = ( s.style === "dashed" || spec.provisional );
|
|
1469
|
-
if ( s.band ) {
|
|
1470
|
-
svg.appendChild( svgEl( "polygon", { points: s.band, class: "ti-chart-line-band" + tone } ) );
|
|
1471
|
-
}
|
|
1472
|
-
for ( let g = 0; g < s.segments.length; g++ ) {
|
|
1473
|
-
const pts = s.segments[ g ].split( " " );
|
|
1474
|
-
if ( provisionalLast && i === 0 && g === s.segments.length - 1 && pts.length >= 2 ) {
|
|
1475
|
-
// split the final connector as a dashed "provisional" segment (active cycle still in flight)
|
|
1476
|
-
const solid = pts.slice( 0, pts.length - 1 );
|
|
1477
|
-
if ( solid.length >= 2 ) {
|
|
1478
|
-
svg.appendChild( svgEl( "polyline", { points: solid.join( " " ), class: "ti-chart-line-series" + tone, fill: "none" } ) );
|
|
1479
|
-
}
|
|
1480
|
-
const tail = svgEl( "polyline", { points: pts.slice( pts.length - 2 ).join( " " ), class: "ti-chart-line-series" + tone, fill: "none" } );
|
|
1481
|
-
tail.setAttribute( "stroke-dasharray", "3 2" );
|
|
1482
|
-
svg.appendChild( tail );
|
|
1483
|
-
} else {
|
|
1484
|
-
const pl = svgEl( "polyline", { points: s.segments[ g ], class: "ti-chart-line-series" + tone, fill: "none" } );
|
|
1485
|
-
if ( dashed ) {
|
|
1486
|
-
pl.setAttribute( "stroke-dasharray", "3 2" );
|
|
1487
|
-
}
|
|
1488
|
-
svg.appendChild( pl );
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
for ( let d = 0; d < s.dots.length; d++ ) {
|
|
1492
|
-
const dot = s.dots[ d ];
|
|
1493
|
-
let dotCls = "ti-chart-line-dot" + tone;
|
|
1494
|
-
if ( provisionalLast && i === 0 && dot.xIndex === layout.n - 1 ) {
|
|
1495
|
-
dotCls += " provisional";
|
|
1496
|
-
}
|
|
1497
|
-
svg.appendChild( svgEl( "circle", { cx: dot.x, cy: dot.y, r: layout.sparkline ? 0.8 : 1.1, class: dotCls } ) );
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
if ( !layout.sparkline ) {
|
|
1502
|
-
for ( let i = 0; i < x.length; i++ ) {
|
|
1503
|
-
const lx = ( layout.n <= 1 ) ? _round( layout.padL + ( layout.innerW / 2 ) ) : _round( layout.padL + ( ( layout.innerW * i ) / ( layout.n - 1 ) ) );
|
|
1504
|
-
const t = svgEl( "text", { x: lx, y: _round( layout.H - 2 ), class: "ti-chart-line-xlabel", "text-anchor": "middle" } );
|
|
1505
|
-
t.textContent = String( ( x[ i ].label !== undefined ) ? x[ i ].label : x[ i ].id );
|
|
1506
|
-
svg.appendChild( t );
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
figure.appendChild( svg );
|
|
1511
|
-
|
|
1512
|
-
const headers = [ "Cycle" ].concat( series.map( ( s ) => s.key ) );
|
|
1513
|
-
const srRows = x.map( ( xi, i ) => {
|
|
1514
|
-
const row = [ String( ( xi.label !== undefined ) ? xi.label : xi.id ) ];
|
|
1515
|
-
for ( let j = 0; j < series.length; j++ ) {
|
|
1516
|
-
const v = Array.isArray( series[ j ].values ) ? series[ j ].values[ i ] : null;
|
|
1517
|
-
row.push( ( typeof v === "number" ) ? formatNumber( v, 2 ) : "—" );
|
|
1518
|
-
}
|
|
1519
|
-
return row;
|
|
1520
|
-
} );
|
|
1521
|
-
figure.appendChild( buildSrTable( headers, srRows ) );
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
/**
|
|
1525
|
-
* Top-level dispatcher: clears the host figure, normalizes the spec, routes to a renderer.
|
|
1526
|
-
* @param {Element} figure host <figure class="ti-chart">
|
|
1527
|
-
* @param {*} rawSpec
|
|
1528
|
-
*/
|
|
1529
|
-
function renderChart( figure, rawSpec ) {
|
|
1530
|
-
if ( !figure ) {
|
|
1531
|
-
return;
|
|
1532
|
-
}
|
|
1533
|
-
const spec = normalizeSpec( rawSpec );
|
|
1534
|
-
_clearChildren( figure );
|
|
1535
|
-
figure.removeAttribute( "data-ti-chart-empty" );
|
|
1536
|
-
figure.removeAttribute( "aria-label" );
|
|
1537
|
-
figure.setAttribute( "role", "img" );
|
|
1538
|
-
figure.setAttribute( "data-ti-chart-type", spec.type ); // per-type sizing hook for CSS (cap + centering)
|
|
1539
|
-
if ( spec.a11yLabel ) {
|
|
1540
|
-
figure.setAttribute( "aria-label", spec.a11yLabel );
|
|
1541
|
-
}
|
|
1542
|
-
if ( spec.type === "gauge" ) {
|
|
1543
|
-
renderGauge( figure, spec );
|
|
1544
|
-
} else if ( spec.type === "bars" ) {
|
|
1545
|
-
renderBars( figure, spec );
|
|
1546
|
-
} else if ( spec.type === "stat" ) {
|
|
1547
|
-
renderStat( figure, spec );
|
|
1548
|
-
} else if ( spec.type === "scatter" ) {
|
|
1549
|
-
renderScatter( figure, spec );
|
|
1550
|
-
} else if ( spec.type === "heatmap" ) {
|
|
1551
|
-
renderHeatmap( figure, spec );
|
|
1552
|
-
} else if ( spec.type === "box" ) {
|
|
1553
|
-
renderBox( figure, spec );
|
|
1554
|
-
} else if ( spec.type === "radar" ) {
|
|
1555
|
-
renderRadar( figure, spec );
|
|
1556
|
-
} else if ( spec.type === "line" ) {
|
|
1557
|
-
renderLine( figure, spec );
|
|
1558
|
-
} else {
|
|
1559
|
-
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
return {
|
|
1564
|
-
SVG_NS,
|
|
1565
|
-
gaugeValueToAngle,
|
|
1566
|
-
gaugeArcPath,
|
|
1567
|
-
barSegments,
|
|
1568
|
-
normalizeSpec,
|
|
1569
|
-
gaugeRowsLayout,
|
|
1570
|
-
scatterLayout,
|
|
1571
|
-
quantileBucket,
|
|
1572
|
-
heatmapLayout,
|
|
1573
|
-
boxLayout,
|
|
1574
|
-
barsGroupedLayout,
|
|
1575
|
-
barsDivergingLayout,
|
|
1576
|
-
radarLayout,
|
|
1577
|
-
lineLayout,
|
|
1578
|
-
svgEl,
|
|
1579
|
-
buildSrTable,
|
|
1580
|
-
renderChart,
|
|
1581
|
-
formatPercent,
|
|
1582
|
-
formatNumber
|
|
1583
|
-
};
|
|
1584
|
-
} )();
|
|
1585
|
-
|
|
1586
|
-
if ( typeof module !== "undefined" && module.exports ) {
|
|
1587
|
-
module.exports = TiCharts;
|
|
1588
|
-
}
|
|
1589
|
-
if ( typeof window !== "undefined" ) {
|
|
1590
|
-
window.TiCharts = TiCharts;
|
|
1591
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ti-charts — hand-rolled, themeable, CSP-safe SVG chart primitives for the ti-engine web apps.
|
|
13
|
+
* Phase 0 ships three primitives: gauge, bars, stat. All scale/path/format math lives in the pure
|
|
14
|
+
* helpers below (unit-tested via node:test); the renderers build SVG via createElementNS +
|
|
15
|
+
* setAttribute only. Dynamic visuals are presentation attributes or CSS classes — never
|
|
16
|
+
* element.style.* (the single sanctioned exception is element.style.setProperty( "--var", … )).
|
|
17
|
+
*/
|
|
18
|
+
const TiCharts = ( function () {
|
|
19
|
+
|
|
20
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clamps a 0..1 value and maps it onto an arc beginning at `startAngle` (degrees, SVG screen
|
|
24
|
+
* convention: 0°=east, +y=down/clockwise) spanning `sweep` degrees clockwise.
|
|
25
|
+
* @param {number} value 0..1
|
|
26
|
+
* @param {number} startAngle degrees
|
|
27
|
+
* @param {number} sweep degrees (positive = clockwise)
|
|
28
|
+
* @returns {number}
|
|
29
|
+
*/
|
|
30
|
+
function gaugeValueToAngle( value, startAngle, sweep ) {
|
|
31
|
+
let v = value;
|
|
32
|
+
if ( v < 0 || Number.isNaN( v ) ) {
|
|
33
|
+
v = 0;
|
|
34
|
+
}
|
|
35
|
+
if ( v > 1 ) {
|
|
36
|
+
v = 1;
|
|
37
|
+
}
|
|
38
|
+
return startAngle + ( v * sweep );
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Converts polar (cx, cy, r, angleDeg, degrees) to a Cartesian { x, y } point.
|
|
42
|
+
function _polar( cx, cy, r, angleDeg ) {
|
|
43
|
+
const a = ( angleDeg * Math.PI ) / 180;
|
|
44
|
+
return { x: cx + ( r * Math.cos( a ) ), y: cy + ( r * Math.sin( a ) ) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Rounds to 2 decimals to keep SVG path strings compact.
|
|
48
|
+
function _round( n ) {
|
|
49
|
+
return Math.round( n * 100 ) / 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Builds an SVG path "d" for a gauge value arc on a circle radius r centred at (cx,cy),
|
|
54
|
+
* running clockwise from `startAngle` for value·sweep degrees.
|
|
55
|
+
* @param {number} value 0..1
|
|
56
|
+
* @param {{cx:number,cy:number,r:number,startAngle:number,sweep:number}} opts
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function gaugeArcPath( value, opts ) {
|
|
60
|
+
const cx = opts.cx, cy = opts.cy, r = opts.r;
|
|
61
|
+
const startAngle = opts.startAngle, sweep = opts.sweep;
|
|
62
|
+
const endAngle = gaugeValueToAngle( value, startAngle, sweep );
|
|
63
|
+
const start = _polar( cx, cy, r, startAngle );
|
|
64
|
+
const end = _polar( cx, cy, r, endAngle );
|
|
65
|
+
const spanned = endAngle - startAngle;
|
|
66
|
+
const largeArc = ( spanned > 180 ) ? 1 : 0;
|
|
67
|
+
const sweepFlag = 1; // clockwise
|
|
68
|
+
return "M" + _round( start.x ) + " " + _round( start.y ) +
|
|
69
|
+
" A" + r + " " + r + " 0 " + largeArc + " " + sweepFlag + " " + _round( end.x ) + " " + _round( end.y );
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lays out horizontal/stacked bar segments along a track. Segment widths are proportional to
|
|
74
|
+
* each segment's `v`; offsets accumulate left-to-right. With `opts.total` the denominator is
|
|
75
|
+
* fixed (e.g. a roster size) so partial rows do not stretch to fill; otherwise the segments
|
|
76
|
+
* normalize to their own sum.
|
|
77
|
+
* @param {Array<{key:string,v:number,tone?:string}>} segments
|
|
78
|
+
* @param {{width:number,total?:number}} opts
|
|
79
|
+
* @returns {Array<{key:string,tone:string,x:number,width:number}>}
|
|
80
|
+
*/
|
|
81
|
+
function barSegments( segments, opts ) {
|
|
82
|
+
const width = opts.width;
|
|
83
|
+
let denom = opts.total;
|
|
84
|
+
if ( denom === undefined || denom === null ) {
|
|
85
|
+
denom = 0;
|
|
86
|
+
for ( let i = 0; i < segments.length; i++ ) {
|
|
87
|
+
denom += ( segments[ i ].v || 0 );
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const out = [];
|
|
91
|
+
let cursor = 0;
|
|
92
|
+
for ( let i = 0; i < segments.length; i++ ) {
|
|
93
|
+
const v = segments[ i ].v || 0;
|
|
94
|
+
const w = ( denom > 0 ) ? _round( ( v / denom ) * width ) : 0;
|
|
95
|
+
out.push( {
|
|
96
|
+
key: segments[ i ].key,
|
|
97
|
+
tone: segments[ i ].tone || "",
|
|
98
|
+
x: _round( cursor ),
|
|
99
|
+
width: w
|
|
100
|
+
} );
|
|
101
|
+
cursor += w;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Formats a 0..1 ratio as a percent string. Clamps to [0,1]; null/undefined/NaN → em dash.
|
|
108
|
+
* @param {number} ratio
|
|
109
|
+
* @param {number} [digits=0]
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function formatPercent( ratio, digits ) {
|
|
113
|
+
if ( ratio === null || ratio === undefined || Number.isNaN( ratio ) ) {
|
|
114
|
+
return "—";
|
|
115
|
+
}
|
|
116
|
+
const d = ( typeof digits === "number" ) ? digits : 0;
|
|
117
|
+
let r = ratio;
|
|
118
|
+
if ( r < 0 ) {
|
|
119
|
+
r = 0;
|
|
120
|
+
}
|
|
121
|
+
if ( r > 1 ) {
|
|
122
|
+
r = 1;
|
|
123
|
+
}
|
|
124
|
+
return ( r * 100 ).toFixed( d ) + "%";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Formats a number to fixed digits (default 0). null/undefined/NaN → em dash.
|
|
129
|
+
* @param {number} value
|
|
130
|
+
* @param {number} [digits=0]
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
function formatNumber( value, digits ) {
|
|
134
|
+
if ( value === null || value === undefined || Number.isNaN( value ) ) {
|
|
135
|
+
return "—";
|
|
136
|
+
}
|
|
137
|
+
const d = ( typeof digits === "number" ) ? digits : 0;
|
|
138
|
+
return Number( value ).toFixed( d );
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Creates an SVG-namespaced element and applies every attribute via setAttribute (CSP-legal).
|
|
143
|
+
* Never touches element.style. Pass `doc` to inject a document in tests.
|
|
144
|
+
* @param {string} tag
|
|
145
|
+
* @param {Object<string,string|number>} attrs
|
|
146
|
+
* @param {Document} [doc]
|
|
147
|
+
* @returns {Element}
|
|
148
|
+
*/
|
|
149
|
+
function svgEl( tag, attrs, doc ) {
|
|
150
|
+
const d = doc || ( typeof document !== "undefined" ? document : null );
|
|
151
|
+
const el = d.createElementNS( SVG_NS, tag );
|
|
152
|
+
if ( attrs ) {
|
|
153
|
+
const keys = Object.keys( attrs );
|
|
154
|
+
for ( let i = 0; i < keys.length; i++ ) {
|
|
155
|
+
el.setAttribute( keys[ i ], String( attrs[ keys[ i ] ] ) );
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return el;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Builds the visually-hidden HTML <table class="ti-chart-sr"> a11y mirror placed beside the <svg>.
|
|
163
|
+
* @param {string[]} headers
|
|
164
|
+
* @param {Array<Array<string|number>>} rows
|
|
165
|
+
* @param {Document} [doc]
|
|
166
|
+
* @returns {HTMLTableElement}
|
|
167
|
+
*/
|
|
168
|
+
function buildSrTable( headers, rows, doc ) {
|
|
169
|
+
const d = doc || ( typeof document !== "undefined" ? document : null );
|
|
170
|
+
const table = d.createElement( "table" );
|
|
171
|
+
table.setAttribute( "class", "ti-chart-sr" );
|
|
172
|
+
const thead = d.createElement( "thead" );
|
|
173
|
+
const htr = d.createElement( "tr" );
|
|
174
|
+
for ( let i = 0; i < headers.length; i++ ) {
|
|
175
|
+
const th = d.createElement( "th" );
|
|
176
|
+
th.textContent = String( headers[ i ] );
|
|
177
|
+
htr.appendChild( th );
|
|
178
|
+
}
|
|
179
|
+
thead.appendChild( htr );
|
|
180
|
+
table.appendChild( thead );
|
|
181
|
+
const tbody = d.createElement( "tbody" );
|
|
182
|
+
for ( let r = 0; r < rows.length; r++ ) {
|
|
183
|
+
const tr = d.createElement( "tr" );
|
|
184
|
+
for ( let c = 0; c < rows[ r ].length; c++ ) {
|
|
185
|
+
const td = d.createElement( "td" );
|
|
186
|
+
td.textContent = String( rows[ r ][ c ] );
|
|
187
|
+
tr.appendChild( td );
|
|
188
|
+
}
|
|
189
|
+
tbody.appendChild( tr );
|
|
190
|
+
}
|
|
191
|
+
table.appendChild( tbody );
|
|
192
|
+
return table;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Lays out Coverage gauge sub-rows: each row's coverage ratio (explicit `value`, else n/total)
|
|
197
|
+
* and its bar width along the track. Guards total=0.
|
|
198
|
+
* @param {Array<{id:string,name:string,value?:number,n?:number,total?:number,tone?:string}>} rows
|
|
199
|
+
* @param {{width:number}} opts
|
|
200
|
+
* @returns {Array<{id:string,label:string,ratio:number,width:number,tone:string}>}
|
|
201
|
+
*/
|
|
202
|
+
function gaugeRowsLayout( rows, opts ) {
|
|
203
|
+
const width = opts.width;
|
|
204
|
+
const out = [];
|
|
205
|
+
for ( let i = 0; i < rows.length; i++ ) {
|
|
206
|
+
const row = rows[ i ];
|
|
207
|
+
let ratio;
|
|
208
|
+
if ( typeof row.value === "number" ) {
|
|
209
|
+
ratio = row.value;
|
|
210
|
+
} else if ( row.total ) {
|
|
211
|
+
ratio = ( row.n || 0 ) / row.total;
|
|
212
|
+
} else {
|
|
213
|
+
ratio = 0;
|
|
214
|
+
}
|
|
215
|
+
if ( Number.isNaN( ratio ) ) {
|
|
216
|
+
ratio = 0;
|
|
217
|
+
}
|
|
218
|
+
if ( ratio < 0 ) {
|
|
219
|
+
ratio = 0;
|
|
220
|
+
}
|
|
221
|
+
if ( ratio > 1 ) {
|
|
222
|
+
ratio = 1;
|
|
223
|
+
}
|
|
224
|
+
out.push( {
|
|
225
|
+
id: row.id,
|
|
226
|
+
label: row.name || row.id,
|
|
227
|
+
ratio: ratio,
|
|
228
|
+
width: _round( ratio * width ),
|
|
229
|
+
tone: row.tone || ""
|
|
230
|
+
} );
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Clamps n into [lo,hi].
|
|
236
|
+
function _clamp( n, lo, hi ) {
|
|
237
|
+
if ( Number.isNaN( n ) ) {
|
|
238
|
+
return lo;
|
|
239
|
+
}
|
|
240
|
+
if ( n < lo ) {
|
|
241
|
+
return lo;
|
|
242
|
+
}
|
|
243
|
+
if ( n > hi ) {
|
|
244
|
+
return hi;
|
|
245
|
+
}
|
|
246
|
+
return n;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Maps scatter points + reference geometry from a data domain into the SVG plot box. Pure: no DOM.
|
|
251
|
+
* x grows left→right; y is INVERTED (data max at the top). Out-of-domain points are clamped into the box.
|
|
252
|
+
* Default domain is grade-weight space 0..1.3. Bubble radius scales by z when `options.bubble === "z"`.
|
|
253
|
+
*
|
|
254
|
+
* @param {Array<{id,x,y,z?,r?,tone?,label?}>} points
|
|
255
|
+
* @param {Object} [opts] {width=100,height=100,pad=10,domain:{xMin,xMax,yMin,yMax},midX?,midY?,bubble?,zMax?,rDefault?,rMin?,rMax?,anonymize?}
|
|
256
|
+
* @returns {{points:Array,diagonal:Object|null,midX:Object|null,midY:Object|null,bounds:Object}}
|
|
257
|
+
*/
|
|
258
|
+
function scatterLayout( points, opts ) {
|
|
259
|
+
const o = opts || {};
|
|
260
|
+
const width = ( typeof o.width === "number" ) ? o.width : 100;
|
|
261
|
+
const height = ( typeof o.height === "number" ) ? o.height : 100;
|
|
262
|
+
const pad = ( typeof o.pad === "number" ) ? o.pad : 10;
|
|
263
|
+
const dom = o.domain || {};
|
|
264
|
+
const xMin = ( typeof dom.xMin === "number" ) ? dom.xMin : 0;
|
|
265
|
+
const xMax = ( typeof dom.xMax === "number" ) ? dom.xMax : 1.3;
|
|
266
|
+
const yMin = ( typeof dom.yMin === "number" ) ? dom.yMin : 0;
|
|
267
|
+
const yMax = ( typeof dom.yMax === "number" ) ? dom.yMax : 1.3;
|
|
268
|
+
const plotW = width - ( 2 * pad );
|
|
269
|
+
const plotH = height - ( 2 * pad );
|
|
270
|
+
const xSpan = ( xMax - xMin ) || 1;
|
|
271
|
+
const ySpan = ( yMax - yMin ) || 1;
|
|
272
|
+
const rDefault = ( typeof o.rDefault === "number" ) ? o.rDefault : 2.2;
|
|
273
|
+
const rMin = ( typeof o.rMin === "number" ) ? o.rMin : 1.4;
|
|
274
|
+
const rMax = ( typeof o.rMax === "number" ) ? o.rMax : 4;
|
|
275
|
+
const zMax = ( typeof o.zMax === "number" && o.zMax > 0 ) ? o.zMax : 1;
|
|
276
|
+
const anonymize = !!o.anonymize;
|
|
277
|
+
|
|
278
|
+
const xScale = ( x ) => _round( pad + ( ( _clamp( x, xMin, xMax ) - xMin ) / xSpan ) * plotW );
|
|
279
|
+
const yScale = ( y ) => _round( pad + plotH - ( ( _clamp( y, yMin, yMax ) - yMin ) / ySpan ) * plotH );
|
|
280
|
+
|
|
281
|
+
const laid = [];
|
|
282
|
+
const list = Array.isArray( points ) ? points : [];
|
|
283
|
+
for ( let i = 0; i < list.length; i++ ) {
|
|
284
|
+
const p = list[ i ];
|
|
285
|
+
const x = ( typeof p.x === "number" ) ? p.x : 0;
|
|
286
|
+
const y = ( typeof p.y === "number" ) ? p.y : 0;
|
|
287
|
+
const z = ( typeof p.z === "number" ) ? p.z : null;
|
|
288
|
+
let r = ( typeof p.r === "number" ) ? p.r : rDefault;
|
|
289
|
+
if ( o.bubble === "z" && z !== null ) {
|
|
290
|
+
r = _round( rMin + ( _clamp( z, 0, zMax ) / zMax ) * ( rMax - rMin ) );
|
|
291
|
+
}
|
|
292
|
+
laid.push( {
|
|
293
|
+
id: p.id,
|
|
294
|
+
label: anonymize ? "" : ( ( p.label !== undefined ) ? p.label : null ),
|
|
295
|
+
tone: p.tone || "",
|
|
296
|
+
x: x, y: y, z: z,
|
|
297
|
+
cx: xScale( x ), cy: yScale( y ), r: r
|
|
298
|
+
} );
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The diagonal (y=x reference) is opt-in via opts.diagonal; the renderer forwards data.diagonal.
|
|
302
|
+
let diagonal = null;
|
|
303
|
+
if ( o.diagonal ) {
|
|
304
|
+
diagonal = { x1: xScale( xMin ), y1: yScale( yMin ), x2: xScale( xMax ), y2: yScale( yMax ) };
|
|
305
|
+
}
|
|
306
|
+
const midX = ( typeof o.midX === "number" ) ? { x: xScale( o.midX ), y1: _round( pad ), y2: _round( pad + plotH ) } : null;
|
|
307
|
+
const midY = ( typeof o.midY === "number" ) ? { x1: _round( pad ), x2: _round( pad + plotW ), y: yScale( o.midY ) } : null;
|
|
308
|
+
|
|
309
|
+
return { points: laid, diagonal: diagonal, midX: midX, midY: midY, bounds: { pad: pad, plotW: plotW, plotH: plotH, width: width, height: height } };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Nearest-rank quantile bucket (1..buckets) for value `v` among `values`. All-equal (or empty) inputs collapse to
|
|
314
|
+
* the middle bucket so a flat heatmap row does not render as all-max. Pure.
|
|
315
|
+
* @param {Array<number>} values
|
|
316
|
+
* @param {number} v
|
|
317
|
+
* @param {number} [buckets=5]
|
|
318
|
+
* @returns {number}
|
|
319
|
+
*/
|
|
320
|
+
function quantileBucket( values, v, buckets ) {
|
|
321
|
+
const b = ( typeof buckets === "number" && buckets > 0 ) ? buckets : 5;
|
|
322
|
+
const arr = Array.isArray( values ) ? values.filter( ( n ) => typeof n === "number" && !Number.isNaN( n ) ) : [];
|
|
323
|
+
const n = arr.length;
|
|
324
|
+
if ( n === 0 ) {
|
|
325
|
+
return Math.ceil( b / 2 );
|
|
326
|
+
}
|
|
327
|
+
let min = arr[ 0 ], max = arr[ 0 ];
|
|
328
|
+
for ( let i = 1; i < n; i++ ) {
|
|
329
|
+
if ( arr[ i ] < min ) {
|
|
330
|
+
min = arr[ i ];
|
|
331
|
+
}
|
|
332
|
+
if ( arr[ i ] > max ) {
|
|
333
|
+
max = arr[ i ];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if ( min === max ) {
|
|
337
|
+
return Math.ceil( b / 2 );
|
|
338
|
+
}
|
|
339
|
+
let le = 0;
|
|
340
|
+
for ( let i = 0; i < n; i++ ) {
|
|
341
|
+
if ( arr[ i ] <= v ) {
|
|
342
|
+
le += 1;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return _clamp( Math.ceil( ( le / n ) * b ), 1, b );
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Lays out a heatmap grid. Sequential mode buckets each cell's `v` into 1..buckets (quantileBucket); diverging
|
|
350
|
+
* mode classifies each cell's `delta` as pos/neg/zero with a 0..1 magnitude vs the cohort max |delta|. Pure.
|
|
351
|
+
* @param {Array<{id,label}>} rows
|
|
352
|
+
* @param {Array<{id,label}>} cols
|
|
353
|
+
* @param {Array<{r,c,v?,n?,expected?,delta?,suppressed?}>} cells
|
|
354
|
+
* @param {Object} [opts] {width=100,rowLabelW=18,colLabelH=8,cellH=10,scale,buckets}
|
|
355
|
+
* @returns {{cells:Array,rowLabels:Array,colLabels:Array,gridW:number,gridH:number,width:number,height:number}}
|
|
356
|
+
*/
|
|
357
|
+
function heatmapLayout( rows, cols, cells, opts ) {
|
|
358
|
+
const o = opts || {};
|
|
359
|
+
const width = ( typeof o.width === "number" ) ? o.width : 100;
|
|
360
|
+
const rowLabelW = ( typeof o.rowLabelW === "number" ) ? o.rowLabelW : 18;
|
|
361
|
+
const colLabelH = ( typeof o.colLabelH === "number" ) ? o.colLabelH : 8;
|
|
362
|
+
const cellH = ( typeof o.cellH === "number" ) ? o.cellH : 10;
|
|
363
|
+
const scale = ( o.scale === "diverging" ) ? "diverging" : "sequential";
|
|
364
|
+
const buckets = ( typeof o.buckets === "number" ) ? o.buckets : 5;
|
|
365
|
+
const R = Array.isArray( rows ) ? rows : [];
|
|
366
|
+
const C = Array.isArray( cols ) ? cols : [];
|
|
367
|
+
const cellList = Array.isArray( cells ) ? cells : [];
|
|
368
|
+
const M = C.length;
|
|
369
|
+
const gridW = width - rowLabelW;
|
|
370
|
+
const cellW = ( M > 0 ) ? _round( gridW / M ) : 0;
|
|
371
|
+
const gridH = R.length * cellH;
|
|
372
|
+
|
|
373
|
+
const seqValues = cellList.filter( ( cell ) => !cell.suppressed && typeof cell.v === "number" ).map( ( cell ) => cell.v );
|
|
374
|
+
let maxAbs = 0;
|
|
375
|
+
for ( let i = 0; i < cellList.length; i++ ) {
|
|
376
|
+
const d = cellList[ i ].delta;
|
|
377
|
+
if ( typeof d === "number" && Math.abs( d ) > maxAbs ) {
|
|
378
|
+
maxAbs = Math.abs( d );
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const laidCells = cellList.map( ( cell ) => {
|
|
383
|
+
const out = {
|
|
384
|
+
r: cell.r, c: cell.c,
|
|
385
|
+
x: _round( rowLabelW + ( cell.c * cellW ) ),
|
|
386
|
+
y: _round( colLabelH + ( cell.r * cellH ) ),
|
|
387
|
+
w: cellW, h: cellH,
|
|
388
|
+
v: ( typeof cell.v === "number" ) ? cell.v : null,
|
|
389
|
+
n: ( typeof cell.n === "number" ) ? cell.n : null,
|
|
390
|
+
expected: ( typeof cell.expected === "number" ) ? cell.expected : null,
|
|
391
|
+
delta: ( typeof cell.delta === "number" ) ? cell.delta : null,
|
|
392
|
+
suppressed: !!cell.suppressed
|
|
393
|
+
};
|
|
394
|
+
if ( !out.suppressed ) {
|
|
395
|
+
if ( scale === "sequential" ) {
|
|
396
|
+
out.bucket = quantileBucket( seqValues, out.v, buckets );
|
|
397
|
+
} else {
|
|
398
|
+
const d = out.delta || 0;
|
|
399
|
+
out.sign = ( d > 0 ) ? "pos" : ( d < 0 ? "neg" : "zero" );
|
|
400
|
+
out.mag = ( maxAbs > 0 ) ? _round( Math.min( 1, Math.abs( d ) / maxAbs ) ) : 0;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
} );
|
|
405
|
+
|
|
406
|
+
const rowLabels = R.map( ( row, i ) => ( {
|
|
407
|
+
id: row.id,
|
|
408
|
+
label: row.label || row.id,
|
|
409
|
+
x: _round( rowLabelW - 1 ),
|
|
410
|
+
y: _round( colLabelH + ( i * cellH ) + ( cellH / 2 ) )
|
|
411
|
+
} ) );
|
|
412
|
+
const colLabels = C.map( ( col, i ) => ( {
|
|
413
|
+
id: col.id,
|
|
414
|
+
label: col.label || col.id,
|
|
415
|
+
x: _round( rowLabelW + ( i * cellW ) + ( cellW / 2 ) ),
|
|
416
|
+
y: _round( colLabelH - 2 )
|
|
417
|
+
} ) );
|
|
418
|
+
|
|
419
|
+
return { cells: laidCells, rowLabels: rowLabels, colLabels: colLabels, gridW: gridW, gridH: gridH, width: width, height: colLabelH + gridH };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Lays out box-plots (one box per group) over a score domain (default 0..150). Score→y is INVERTED (domain.max at
|
|
424
|
+
* the top). Each group maps its five-number summary + optional expected/mean markers; global `reference` lines map
|
|
425
|
+
* too. Suppressed groups carry through without box geometry. Pure.
|
|
426
|
+
* @param {Array<{id,label,min,q1,median,q3,max,n?,mean?,expected?,suppressed?}>} groups
|
|
427
|
+
* @param {Object} [opts] {width=100,height=70,pad=8,padLeft=12,padBottom=10,domain:{min,max},reference:[{v,label}]}
|
|
428
|
+
* @returns {{boxes:Array,refs:Array,axis:Object,width:number,height:number}}
|
|
429
|
+
*/
|
|
430
|
+
function boxLayout( groups, opts ) {
|
|
431
|
+
const o = opts || {};
|
|
432
|
+
const width = ( typeof o.width === "number" ) ? o.width : 100;
|
|
433
|
+
const height = ( typeof o.height === "number" ) ? o.height : 70;
|
|
434
|
+
const pad = ( typeof o.pad === "number" ) ? o.pad : 8;
|
|
435
|
+
const padLeft = ( typeof o.padLeft === "number" ) ? o.padLeft : 12;
|
|
436
|
+
const padBottom = ( typeof o.padBottom === "number" ) ? o.padBottom : 10;
|
|
437
|
+
const dom = o.domain || {};
|
|
438
|
+
const dMin = ( typeof dom.min === "number" ) ? dom.min : 0;
|
|
439
|
+
const dMax = ( typeof dom.max === "number" ) ? dom.max : 150;
|
|
440
|
+
const span = ( dMax - dMin ) || 1;
|
|
441
|
+
const plotTop = pad;
|
|
442
|
+
const plotBottom = height - padBottom;
|
|
443
|
+
const plotH = plotBottom - plotTop;
|
|
444
|
+
const plotLeft = padLeft;
|
|
445
|
+
const plotRight = width - pad;
|
|
446
|
+
const plotW = plotRight - plotLeft;
|
|
447
|
+
const G = Array.isArray( groups ) ? groups : [];
|
|
448
|
+
const slot = ( G.length > 0 ) ? ( plotW / G.length ) : 0;
|
|
449
|
+
const boxW = _round( slot * 0.5 );
|
|
450
|
+
|
|
451
|
+
const yFor = ( score ) => _round( plotBottom - ( ( _clamp( score, dMin, dMax ) - dMin ) / span ) * plotH );
|
|
452
|
+
|
|
453
|
+
const boxes = G.map( ( g, i ) => {
|
|
454
|
+
const cx = _round( plotLeft + ( slot * i ) + ( slot / 2 ) );
|
|
455
|
+
if ( g.suppressed ) {
|
|
456
|
+
return {
|
|
457
|
+
id: g.id,
|
|
458
|
+
label: g.label || g.id,
|
|
459
|
+
cx: cx,
|
|
460
|
+
w: boxW,
|
|
461
|
+
x: _round( cx - ( boxW / 2 ) ),
|
|
462
|
+
suppressed: true,
|
|
463
|
+
n: ( typeof g.n === "number" ) ? g.n : 0
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
id: g.id, label: g.label || g.id, cx: cx, w: boxW, x: _round( cx - ( boxW / 2 ) ), suppressed: false,
|
|
468
|
+
n: ( typeof g.n === "number" ) ? g.n : 0,
|
|
469
|
+
min: g.min, q1: g.q1, median: g.median, q3: g.q3, max: g.max,
|
|
470
|
+
yMin: yFor( g.min ), yQ1: yFor( g.q1 ), yMed: yFor( g.median ), yQ3: yFor( g.q3 ), yMax: yFor( g.max ),
|
|
471
|
+
yExpected: ( typeof g.expected === "number" ) ? yFor( g.expected ) : null,
|
|
472
|
+
yMean: ( typeof g.mean === "number" ) ? yFor( g.mean ) : null
|
|
473
|
+
};
|
|
474
|
+
} );
|
|
475
|
+
|
|
476
|
+
const refList = Array.isArray( o.reference ) ? o.reference : [];
|
|
477
|
+
const refs = refList.map( ( ref ) => ( { v: ref.v, label: ref.label || "", y: yFor( ref.v ), x1: _round( plotLeft ), x2: _round( plotRight ) } ) );
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
boxes: boxes,
|
|
481
|
+
refs: refs,
|
|
482
|
+
axis: { plotLeft: plotLeft, plotRight: plotRight, plotTop: plotTop, plotBottom: plotBottom, plotW: plotW, plotH: plotH, yFor: yFor },
|
|
483
|
+
width: width,
|
|
484
|
+
height: height
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Lays out grouped horizontal bars: every row's values share one global max (so widths are comparable across rows);
|
|
490
|
+
* each value becomes a sub-bar stacked vertically inside the row band. Pure.
|
|
491
|
+
* @param {Array<{id,label,values:Array<{key,v,tone?}>}>} rows
|
|
492
|
+
* @param {Object} [opts] {trackW=100,max?,barH=4,gap=2}
|
|
493
|
+
* @returns {{rows:Array,trackW:number,max:number}}
|
|
494
|
+
*/
|
|
495
|
+
function barsGroupedLayout( rows, opts ) {
|
|
496
|
+
const o = opts || {};
|
|
497
|
+
const trackW = ( typeof o.trackW === "number" ) ? o.trackW : 100;
|
|
498
|
+
const barH = ( typeof o.barH === "number" ) ? o.barH : 4;
|
|
499
|
+
const gap = ( typeof o.gap === "number" ) ? o.gap : 2;
|
|
500
|
+
const list = Array.isArray( rows ) ? rows : [];
|
|
501
|
+
let max = ( typeof o.max === "number" ) ? o.max : 0;
|
|
502
|
+
if ( !( typeof o.max === "number" ) ) {
|
|
503
|
+
for ( let i = 0; i < list.length; i++ ) {
|
|
504
|
+
const vals = Array.isArray( list[ i ].values ) ? list[ i ].values : [];
|
|
505
|
+
for ( let j = 0; j < vals.length; j++ ) {
|
|
506
|
+
const v = vals[ j ].v || 0;
|
|
507
|
+
if ( v > max ) {
|
|
508
|
+
max = v;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const outRows = list.map( ( row ) => {
|
|
514
|
+
const vals = Array.isArray( row.values ) ? row.values : [];
|
|
515
|
+
const bars = vals.map( ( val, i ) => ( {
|
|
516
|
+
key: val.key, v: val.v || 0, tone: val.tone || "",
|
|
517
|
+
width: ( max > 0 ) ? _round( ( ( val.v || 0 ) / max ) * trackW ) : 0,
|
|
518
|
+
subY: _round( i * ( barH + gap ) ), height: barH
|
|
519
|
+
} ) );
|
|
520
|
+
return { id: row.id, label: row.label || row.id, bars: bars, rowHeight: _round( vals.length * ( barH + gap ) ) };
|
|
521
|
+
} );
|
|
522
|
+
return { rows: outRows, trackW: trackW, max: max };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Lays out diverging horizontal bars centered on zero: every value shares one global max-abs; positive values
|
|
527
|
+
* extend right of centre, negative left. Pure.
|
|
528
|
+
* @param {Array<{id,label,values:Array<{key,v,tone?}>}>} rows
|
|
529
|
+
* @param {Object} [opts] {trackW=100,maxAbs?}
|
|
530
|
+
* @returns {{rows:Array,center:number,maxAbs:number}}
|
|
531
|
+
*/
|
|
532
|
+
function barsDivergingLayout( rows, opts ) {
|
|
533
|
+
const o = opts || {};
|
|
534
|
+
const trackW = ( typeof o.trackW === "number" ) ? o.trackW : 100;
|
|
535
|
+
const list = Array.isArray( rows ) ? rows : [];
|
|
536
|
+
let maxAbs = ( typeof o.maxAbs === "number" ) ? o.maxAbs : 0;
|
|
537
|
+
if ( !( typeof o.maxAbs === "number" ) ) {
|
|
538
|
+
for ( let i = 0; i < list.length; i++ ) {
|
|
539
|
+
const vals = Array.isArray( list[ i ].values ) ? list[ i ].values : [];
|
|
540
|
+
for ( let j = 0; j < vals.length; j++ ) {
|
|
541
|
+
const a = Math.abs( vals[ j ].v || 0 );
|
|
542
|
+
if ( a > maxAbs ) {
|
|
543
|
+
maxAbs = a;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const center = _round( trackW / 2 );
|
|
549
|
+
const half = trackW / 2;
|
|
550
|
+
const outRows = list.map( ( row ) => {
|
|
551
|
+
const vals = Array.isArray( row.values ) ? row.values : [];
|
|
552
|
+
const bars = vals.map( ( val ) => {
|
|
553
|
+
const v = val.v || 0;
|
|
554
|
+
const w = ( maxAbs > 0 ) ? _round( ( Math.abs( v ) / maxAbs ) * half ) : 0;
|
|
555
|
+
const dir = ( v >= 0 ) ? "pos" : "neg";
|
|
556
|
+
const x = ( v >= 0 ) ? center : _round( center - w );
|
|
557
|
+
return { key: val.key, v: v, tone: val.tone || "", x: x, width: w, dir: dir };
|
|
558
|
+
} );
|
|
559
|
+
return { id: row.id, label: row.label || row.id, bars: bars };
|
|
560
|
+
} );
|
|
561
|
+
return { rows: outRows, center: center, maxAbs: maxAbs };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Lays out a radar/spider chart: N axes evenly spaced (clockwise from the top), concentric polygon rings, and one
|
|
566
|
+
* polygon per series with each vertex at radius rMax·(value/axisMax) on its axis. Pure (angle math in JS).
|
|
567
|
+
* @param {Array<{id,label,max}>} axes
|
|
568
|
+
* @param {Array<{key,values:Object,tone?,style?}>} series
|
|
569
|
+
* @param {Object} [opts] {cx=50,cy=50,rMax=36,startAngle=-90,rings=[.25,.5,.75,1],labelPad=7}
|
|
570
|
+
* @returns {{cx,cy,rMax,axes:Array,rings:Array,series:Array}}
|
|
571
|
+
*/
|
|
572
|
+
function radarLayout( axes, series, opts ) {
|
|
573
|
+
const o = opts || {};
|
|
574
|
+
const cx = ( typeof o.cx === "number" ) ? o.cx : 50;
|
|
575
|
+
const cy = ( typeof o.cy === "number" ) ? o.cy : 50;
|
|
576
|
+
const rMax = ( typeof o.rMax === "number" ) ? o.rMax : 36;
|
|
577
|
+
const startAngle = ( typeof o.startAngle === "number" ) ? o.startAngle : -90;
|
|
578
|
+
const labelPad = ( typeof o.labelPad === "number" ) ? o.labelPad : 7;
|
|
579
|
+
const ringFractions = Array.isArray( o.rings ) ? o.rings : [ 0.25, 0.5, 0.75, 1 ];
|
|
580
|
+
const axisList = Array.isArray( axes ) ? axes : [];
|
|
581
|
+
const n = axisList.length;
|
|
582
|
+
const step = ( n > 0 ) ? ( 360 / n ) : 0;
|
|
583
|
+
|
|
584
|
+
const axisGeom = axisList.map( ( ax, i ) => {
|
|
585
|
+
const angle = startAngle + ( i * step );
|
|
586
|
+
const outer = _polar( cx, cy, rMax, angle );
|
|
587
|
+
const label = _polar( cx, cy, rMax + labelPad, angle );
|
|
588
|
+
return {
|
|
589
|
+
id: ax.id, label: ( ax.label !== undefined ) ? ax.label : ax.id,
|
|
590
|
+
max: ( typeof ax.max === "number" && ax.max > 0 ) ? ax.max : 1,
|
|
591
|
+
tone: ax.tone || "",
|
|
592
|
+
angle: angle, outerX: _round( outer.x ), outerY: _round( outer.y ),
|
|
593
|
+
labelX: _round( label.x ), labelY: _round( label.y )
|
|
594
|
+
};
|
|
595
|
+
} );
|
|
596
|
+
|
|
597
|
+
const rings = ringFractions.map( ( frac ) => ( {
|
|
598
|
+
frac: frac,
|
|
599
|
+
points: axisGeom.map( ( ag ) => {
|
|
600
|
+
const p = _polar( cx, cy, rMax * frac, ag.angle );
|
|
601
|
+
return _round( p.x ) + "," + _round( p.y );
|
|
602
|
+
} ).join( " " )
|
|
603
|
+
} ) );
|
|
604
|
+
|
|
605
|
+
const seriesList = Array.isArray( series ) ? series : [];
|
|
606
|
+
const laidSeries = seriesList.map( ( s ) => {
|
|
607
|
+
const dots = [];
|
|
608
|
+
const points = axisGeom.map( ( ag ) => {
|
|
609
|
+
const raw = ( s.values && typeof s.values[ ag.id ] === "number" ) ? s.values[ ag.id ] : 0;
|
|
610
|
+
const ratio = _clamp( raw / ag.max, 0, 1 );
|
|
611
|
+
const p = _polar( cx, cy, rMax * ratio, ag.angle );
|
|
612
|
+
dots.push( { x: _round( p.x ), y: _round( p.y ), axisId: ag.id, value: raw } );
|
|
613
|
+
return _round( p.x ) + "," + _round( p.y );
|
|
614
|
+
} );
|
|
615
|
+
return { key: s.key, tone: s.tone || "", style: s.style || "", points: points.join( " " ), dots: dots };
|
|
616
|
+
} );
|
|
617
|
+
|
|
618
|
+
return { cx: cx, cy: cy, rMax: rMax, axes: axisGeom, rings: rings, series: laidSeries };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Pure layout for the cross-cycle line/trend primitive (CA-X1). Maps a categorical x-axis (cycles, evenly spaced)
|
|
623
|
+
* and N series onto a viewBox. Each series may carry a {p25,p75}-style `band` (drawn as a filled area) and null
|
|
624
|
+
* gaps (the polyline breaks into separate segments — a null is NOT bridged or plotted as 0). The y-domain spans the
|
|
625
|
+
* min/max over all numeric values and band edges, unless `yMax`/`zeroBaseline` pin it.
|
|
626
|
+
*
|
|
627
|
+
* @param {Array<{key,values:Array<number|null>,band?:Array<[number,number]|null>,tone?,style?}>} series
|
|
628
|
+
* @param {Object} [opts] {width=100,height=60|28,xCount,yMax,zeroBaseline=false,sparkline=false}
|
|
629
|
+
* @returns {{W,H,padL,padR,padT,padB,innerW,innerH,n,yMin,yMax,sparkline,series:Array}}
|
|
630
|
+
*/
|
|
631
|
+
function lineLayout( series, opts ) {
|
|
632
|
+
const o = opts || {};
|
|
633
|
+
const sparkline = Boolean( o.sparkline );
|
|
634
|
+
const W = ( typeof o.width === "number" ) ? o.width : 100;
|
|
635
|
+
const H = ( typeof o.height === "number" ) ? o.height : ( sparkline ? 28 : 60 );
|
|
636
|
+
const padL = sparkline ? 1 : 12;
|
|
637
|
+
const padR = sparkline ? 1 : 4;
|
|
638
|
+
const padT = sparkline ? 2 : 4;
|
|
639
|
+
const padB = sparkline ? 2 : 10;
|
|
640
|
+
const seriesList = Array.isArray( series ) ? series : [];
|
|
641
|
+
const n = ( typeof o.xCount === "number" ) ? o.xCount
|
|
642
|
+
: ( seriesList.length && Array.isArray( seriesList[ 0 ].values ) ? seriesList[ 0 ].values.length : 0 );
|
|
643
|
+
const innerW = W - padL - padR;
|
|
644
|
+
const innerH = H - padT - padB;
|
|
645
|
+
|
|
646
|
+
let yMax = ( typeof o.yMax === "number" ) ? o.yMax : Number.NEGATIVE_INFINITY;
|
|
647
|
+
let yMin = o.zeroBaseline ? 0 : Number.POSITIVE_INFINITY;
|
|
648
|
+
for ( const s of seriesList ) {
|
|
649
|
+
const vals = Array.isArray( s.values ) ? s.values : [];
|
|
650
|
+
for ( const v of vals ) {
|
|
651
|
+
if ( typeof v === "number" ) {
|
|
652
|
+
if ( v > yMax ) {
|
|
653
|
+
yMax = v;
|
|
654
|
+
}
|
|
655
|
+
if ( !o.zeroBaseline && v < yMin ) {
|
|
656
|
+
yMin = v;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const band = Array.isArray( s.band ) ? s.band : [];
|
|
661
|
+
for ( const b of band ) {
|
|
662
|
+
if ( b && typeof b[ 1 ] === "number" && b[ 1 ] > yMax ) {
|
|
663
|
+
yMax = b[ 1 ];
|
|
664
|
+
}
|
|
665
|
+
if ( b && !o.zeroBaseline && typeof b[ 0 ] === "number" && b[ 0 ] < yMin ) {
|
|
666
|
+
yMin = b[ 0 ];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if ( !isFinite( yMax ) ) {
|
|
671
|
+
yMax = o.zeroBaseline ? 1 : 0;
|
|
672
|
+
}
|
|
673
|
+
if ( !isFinite( yMin ) ) {
|
|
674
|
+
yMin = 0;
|
|
675
|
+
}
|
|
676
|
+
if ( yMax <= yMin ) {
|
|
677
|
+
yMax = yMin + 1;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const xAt = ( i ) => ( n <= 1 ) ? ( padL + ( innerW / 2 ) ) : ( padL + ( ( innerW * i ) / ( n - 1 ) ) );
|
|
681
|
+
const yAt = ( v ) => padT + ( innerH * ( 1 - ( ( v - yMin ) / ( yMax - yMin ) ) ) );
|
|
682
|
+
|
|
683
|
+
const laidSeries = seriesList.map( ( s ) => {
|
|
684
|
+
const vals = Array.isArray( s.values ) ? s.values : [];
|
|
685
|
+
const dots = [];
|
|
686
|
+
const segments = [];
|
|
687
|
+
let current = [];
|
|
688
|
+
for ( let i = 0; i < n; i++ ) {
|
|
689
|
+
const v = vals[ i ];
|
|
690
|
+
if ( typeof v === "number" ) {
|
|
691
|
+
const x = _round( xAt( i ) ), y = _round( yAt( v ) );
|
|
692
|
+
current.push( x + "," + y );
|
|
693
|
+
dots.push( { x: x, y: y, xIndex: i, value: v } );
|
|
694
|
+
} else if ( current.length ) {
|
|
695
|
+
segments.push( current.join( " " ) );
|
|
696
|
+
current = [];
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if ( current.length ) {
|
|
700
|
+
segments.push( current.join( " " ) );
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let band = null;
|
|
704
|
+
const bandPairs = Array.isArray( s.band ) ? s.band : [];
|
|
705
|
+
if ( bandPairs.length ) {
|
|
706
|
+
const ups = [], los = [];
|
|
707
|
+
for ( let i = 0; i < n; i++ ) {
|
|
708
|
+
const b = bandPairs[ i ];
|
|
709
|
+
if ( b && typeof b[ 0 ] === "number" && typeof b[ 1 ] === "number" ) {
|
|
710
|
+
ups.push( _round( xAt( i ) ) + "," + _round( yAt( b[ 1 ] ) ) );
|
|
711
|
+
los.unshift( _round( xAt( i ) ) + "," + _round( yAt( b[ 0 ] ) ) );
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if ( ups.length ) {
|
|
715
|
+
band = ups.concat( los ).join( " " );
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return { key: s.key, tone: s.tone || "", style: s.style || "", segments: segments, dots: dots, band: band };
|
|
719
|
+
} );
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
W: W,
|
|
723
|
+
H: H,
|
|
724
|
+
padL: padL,
|
|
725
|
+
padR: padR,
|
|
726
|
+
padT: padT,
|
|
727
|
+
padB: padB,
|
|
728
|
+
innerW: innerW,
|
|
729
|
+
innerH: innerH,
|
|
730
|
+
n: n,
|
|
731
|
+
yMin: yMin,
|
|
732
|
+
yMax: yMax,
|
|
733
|
+
sparkline: sparkline,
|
|
734
|
+
series: laidSeries
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Attaches CSP-safe drill interactivity to an element: tabindex/role + click & Enter/Space listeners dispatching a
|
|
740
|
+
* bubbling `ti-chart:select` CustomEvent. In non-DOM environments (unit tests) it sets the a11y attributes and
|
|
741
|
+
* returns without wiring listeners.
|
|
742
|
+
* @param {Element} el
|
|
743
|
+
* @param {Object} detail
|
|
744
|
+
* @param {string} label
|
|
745
|
+
*/
|
|
746
|
+
function _attachSelect( el, detail, label ) {
|
|
747
|
+
el.setAttribute( "tabindex", "0" );
|
|
748
|
+
el.setAttribute( "role", "button" );
|
|
749
|
+
if ( label ) {
|
|
750
|
+
el.setAttribute( "aria-label", label );
|
|
751
|
+
}
|
|
752
|
+
if ( typeof el.addEventListener !== "function" || typeof CustomEvent === "undefined" ) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const fire = () => {
|
|
756
|
+
el.dispatchEvent( new CustomEvent( "ti-chart:select", { detail: detail, bubbles: true } ) );
|
|
757
|
+
};
|
|
758
|
+
el.addEventListener( "click", fire );
|
|
759
|
+
el.addEventListener( "keydown", ( e ) => {
|
|
760
|
+
if ( e.key === "Enter" || e.key === " " ) {
|
|
761
|
+
e.preventDefault();
|
|
762
|
+
fire();
|
|
763
|
+
}
|
|
764
|
+
} );
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const SUPPORTED_TYPES = [ "gauge", "bars", "stat", "scatter", "heatmap", "box", "radar", "line" ]; // P0 gauge/bars/stat; 1A scatter/heatmap/box; P3 radar; P4 line
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* @typedef {Object} TiChartSpec
|
|
771
|
+
* @property {"gauge"|"bars"|"stat"|"scatter"|"heatmap"|"box"|"radar"} type P0: gauge/bars/stat; 1A: scatter/heatmap/box; P3: radar.
|
|
772
|
+
* @property {Object} data per-primitive payload (the aggregation output)
|
|
773
|
+
* @property {Object} [options] domains, sizing, labels, formatting
|
|
774
|
+
* @property {string} a11yLabel role=img label (also injected as <title>)
|
|
775
|
+
* @property {string} [a11yDesc] injected as <desc>
|
|
776
|
+
* @property {boolean} [provisional] draws the "as of now / % reporting" hatch for ACTIVE cycles
|
|
777
|
+
*/
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Validates + fills defaults on a chart spec. Unknown/unsupported types collapse to
|
|
781
|
+
* { type:"unsupported", data:{} } so the renderer can show a graceful empty state.
|
|
782
|
+
* @param {*} spec
|
|
783
|
+
* @returns {TiChartSpec}
|
|
784
|
+
*/
|
|
785
|
+
function normalizeSpec( spec ) {
|
|
786
|
+
if ( !spec || typeof spec !== "object" ) {
|
|
787
|
+
return { type: "unsupported", data: {}, options: {}, a11yLabel: "", a11yDesc: "", provisional: false };
|
|
788
|
+
}
|
|
789
|
+
const supported = ( SUPPORTED_TYPES.indexOf( spec.type ) >= 0 );
|
|
790
|
+
return {
|
|
791
|
+
type: supported ? spec.type : "unsupported",
|
|
792
|
+
data: ( supported && spec.data && typeof spec.data === "object" ) ? spec.data : {},
|
|
793
|
+
options: ( spec.options && typeof spec.options === "object" ) ? spec.options : {},
|
|
794
|
+
a11yLabel: ( typeof spec.a11yLabel === "string" ) ? spec.a11yLabel : "",
|
|
795
|
+
a11yDesc: ( typeof spec.a11yDesc === "string" ) ? spec.a11yDesc : "",
|
|
796
|
+
provisional: Boolean( spec.provisional )
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function _clearChildren( node ) {
|
|
801
|
+
while ( node.firstChild ) {
|
|
802
|
+
node.removeChild( node.firstChild );
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Renders a Coverage-style gauge: a 270° track, a value arc (dashed cap when provisional),
|
|
808
|
+
* a centre value, a label, an optional sublabel ("% reporting" caveat), and optional sub-rows.
|
|
809
|
+
* All geometry from gaugeArcPath/gaugeRowsLayout.
|
|
810
|
+
* @param {Element} figure host <figure class="ti-chart">
|
|
811
|
+
* @param {TiChartSpec} spec
|
|
812
|
+
*/
|
|
813
|
+
function renderGauge( figure, spec ) {
|
|
814
|
+
const data = spec.data;
|
|
815
|
+
const value = ( typeof data.value === "number" ) ? data.value : 0;
|
|
816
|
+
const geom = { cx: 50, cy: 50, r: 42, startAngle: -225, sweep: 270 };
|
|
817
|
+
|
|
818
|
+
const svg = svgEl( "svg", { viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
819
|
+
const title = svgEl( "title", {} );
|
|
820
|
+
title.textContent = spec.a11yLabel;
|
|
821
|
+
svg.appendChild( title );
|
|
822
|
+
if ( spec.a11yDesc ) {
|
|
823
|
+
const desc = svgEl( "desc", {} );
|
|
824
|
+
desc.textContent = spec.a11yDesc;
|
|
825
|
+
svg.appendChild( desc );
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const track = svgEl( "path", { d: gaugeArcPath( 1, geom ), class: "ti-chart-gauge-track", fill: "none" } );
|
|
829
|
+
svg.appendChild( track );
|
|
830
|
+
|
|
831
|
+
const arc = svgEl( "path", { d: gaugeArcPath( value, geom ), class: "ti-chart-gauge-arc", fill: "none" } );
|
|
832
|
+
if ( spec.provisional ) {
|
|
833
|
+
arc.setAttribute( "stroke-dasharray", "4 3" );
|
|
834
|
+
}
|
|
835
|
+
svg.appendChild( arc );
|
|
836
|
+
|
|
837
|
+
const valueText = svgEl( "text", { x: 50, y: 48, class: "ti-chart-gauge-value", "text-anchor": "middle", "dominant-baseline": "central" } );
|
|
838
|
+
valueText.textContent = formatPercent( value );
|
|
839
|
+
svg.appendChild( valueText );
|
|
840
|
+
|
|
841
|
+
if ( data.label ) {
|
|
842
|
+
const labelText = svgEl( "text", { x: 50, y: 62, class: "ti-chart-gauge-label", "text-anchor": "middle" } );
|
|
843
|
+
labelText.textContent = data.label;
|
|
844
|
+
svg.appendChild( labelText );
|
|
845
|
+
}
|
|
846
|
+
if ( data.sublabel ) {
|
|
847
|
+
const subText = svgEl( "text", { x: 50, y: 71, class: "ti-chart-gauge-sublabel", "text-anchor": "middle" } );
|
|
848
|
+
subText.textContent = data.sublabel;
|
|
849
|
+
svg.appendChild( subText );
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
figure.appendChild( svg );
|
|
853
|
+
|
|
854
|
+
// a11y mirror: overall (+ the reporting caveat) + each sub-row
|
|
855
|
+
const headers = [ "Group", "Coverage" ];
|
|
856
|
+
const srRows = [ [ data.label || spec.a11yLabel, formatPercent( value ) ] ];
|
|
857
|
+
if ( data.sublabel ) {
|
|
858
|
+
srRows.push( [ "Reporting", data.sublabel ] );
|
|
859
|
+
}
|
|
860
|
+
if ( Array.isArray( data.rows ) ) {
|
|
861
|
+
const laid = gaugeRowsLayout( data.rows, { width: 100 } );
|
|
862
|
+
for ( let i = 0; i < laid.length; i++ ) {
|
|
863
|
+
srRows.push( [ laid[ i ].label, formatPercent( laid[ i ].ratio ) ] );
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
figure.appendChild( buildSrTable( headers, srRows ) );
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Appends a <title>/<desc> pair to an svg from the spec's a11y fields.
|
|
870
|
+
function _appendA11yTitle( svg, spec ) {
|
|
871
|
+
const title = svgEl( "title", {} );
|
|
872
|
+
title.textContent = spec.a11yLabel;
|
|
873
|
+
svg.appendChild( title );
|
|
874
|
+
if ( spec.a11yDesc ) {
|
|
875
|
+
const desc = svgEl( "desc", {} );
|
|
876
|
+
desc.textContent = spec.a11yDesc;
|
|
877
|
+
svg.appendChild( desc );
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Renders bars. Dispatches on options.mode: "stacked" (default, the Phase-0 horizontal stacked segments),
|
|
883
|
+
* "grouped" (sub-bars per row sharing one global max — R2 time), "diverging" (centered on zero — R6 drivers).
|
|
884
|
+
* @param {Element} figure
|
|
885
|
+
* @param {TiChartSpec} spec
|
|
886
|
+
*/
|
|
887
|
+
function renderBars( figure, spec ) {
|
|
888
|
+
const mode = ( spec.options && spec.options.mode ) || "stacked";
|
|
889
|
+
if ( mode === "grouped" ) {
|
|
890
|
+
return _renderBarsGrouped( figure, spec );
|
|
891
|
+
}
|
|
892
|
+
if ( mode === "diverging" ) {
|
|
893
|
+
return _renderBarsDiverging( figure, spec );
|
|
894
|
+
}
|
|
895
|
+
return _renderBarsStacked( figure, spec );
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Horizontal stacked bars. Each row is a labelled track — a caption line (row label left, optional value right)
|
|
900
|
+
* above a stack of segments laid out by barSegments; a provisional "Not started" tail is dashed/dimmed via the
|
|
901
|
+
* .ti-chart-provisional class. A swatch legend is rendered below the chart when spec.options.legend is provided.
|
|
902
|
+
*/
|
|
903
|
+
function _renderBarsStacked( figure, spec ) {
|
|
904
|
+
const data = spec.data;
|
|
905
|
+
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
906
|
+
if ( rows.length === 0 ) {
|
|
907
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// viewBox units — not CSS pixels. Per row: a caption line (labelH) + the bar (rowH) + a gap. Bars opt out of
|
|
911
|
+
// the global svg max-height (ti-framework.css), so this scale stays identical regardless of how many rows a
|
|
912
|
+
// chart has — a short subtree chart and a tall org-wide one render at the same bar thickness.
|
|
913
|
+
const trackW = 100, rowH = 9, gap = 7, labelH = 5, padTop = 4;
|
|
914
|
+
const height = padTop + ( rows.length * ( labelH + rowH + gap ) );
|
|
915
|
+
|
|
916
|
+
const svg = svgEl( "svg", { viewBox: "0 0 100 " + _round( height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
917
|
+
_appendA11yTitle( svg, spec );
|
|
918
|
+
|
|
919
|
+
const srRows = [];
|
|
920
|
+
let cursorY = padTop;
|
|
921
|
+
for ( let r = 0; r < rows.length; r++ ) {
|
|
922
|
+
const row = rows[ r ];
|
|
923
|
+
// Caption: row label on the left, optional value (e.g. "% complete") right-aligned above the bar.
|
|
924
|
+
const capY = _round( cursorY + labelH - 2 );
|
|
925
|
+
const lbl = svgEl( "text", { x: 0, y: capY, class: "ti-chart-bar-label" } );
|
|
926
|
+
lbl.textContent = row.label || row.id || "";
|
|
927
|
+
svg.appendChild( lbl );
|
|
928
|
+
if ( row.valueLabel ) {
|
|
929
|
+
const val = svgEl( "text", { x: trackW, y: capY, "text-anchor": "end", class: "ti-chart-bar-label" } );
|
|
930
|
+
val.textContent = row.valueLabel;
|
|
931
|
+
svg.appendChild( val );
|
|
932
|
+
}
|
|
933
|
+
const barY = cursorY + labelH;
|
|
934
|
+
const segSource = row.segments || row.values || [];
|
|
935
|
+
const segs = barSegments( segSource, { width: trackW, total: row.total } );
|
|
936
|
+
for ( let s = 0; s < segs.length; s++ ) {
|
|
937
|
+
let cls = "ti-chart-bar-seg";
|
|
938
|
+
if ( segs[ s ].tone ) {
|
|
939
|
+
cls = cls + " tone-" + segs[ s ].tone;
|
|
940
|
+
}
|
|
941
|
+
if ( spec.provisional && segs[ s ].key === "Not started" ) {
|
|
942
|
+
cls = cls + " ti-chart-provisional";
|
|
943
|
+
}
|
|
944
|
+
const rect = svgEl( "rect", { x: segs[ s ].x, y: barY, width: segs[ s ].width, height: rowH, rx: 2, class: cls } );
|
|
945
|
+
svg.appendChild( rect );
|
|
946
|
+
srRows.push( [ row.label || row.id, segs[ s ].key, String( segSource[ s ].v || 0 ) ] );
|
|
947
|
+
}
|
|
948
|
+
cursorY = barY + rowH + gap;
|
|
949
|
+
}
|
|
950
|
+
figure.appendChild( svg );
|
|
951
|
+
if ( spec.options && Array.isArray( spec.options.legend ) && spec.options.legend.length > 0 ) {
|
|
952
|
+
figure.appendChild( _buildChartLegend( spec.options.legend ) );
|
|
953
|
+
}
|
|
954
|
+
figure.appendChild( buildSrTable( [ "Row", "Segment", "Count" ], srRows ) );
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Builds a visible HTML swatch legend appended below a chart. Each entry is { label, tone, dashed? }; the swatch
|
|
959
|
+
* colour routes through the same grade/series tokens as the chart segments via a tone-* class (so it re-themes in
|
|
960
|
+
* step), and a `dashed:true` entry gets an outlined swatch (.is-dashed) to denote a dashed series (e.g. expected).
|
|
961
|
+
*
|
|
962
|
+
* @param {Array<{label:string,tone:string,dashed?:boolean}>} items
|
|
963
|
+
* @returns {HTMLElement}
|
|
964
|
+
*/
|
|
965
|
+
function _buildChartLegend( items ) {
|
|
966
|
+
const wrap = document.createElement( "div" );
|
|
967
|
+
wrap.className = "ti-chart-legend";
|
|
968
|
+
for ( let i = 0; i < items.length; i++ ) {
|
|
969
|
+
const item = document.createElement( "span" );
|
|
970
|
+
item.className = "ti-chart-legend-item";
|
|
971
|
+
const swatch = document.createElement( "span" );
|
|
972
|
+
swatch.className = "ti-chart-legend-swatch" + ( items[ i ].tone ? ( " tone-" + items[ i ].tone ) : "" ) + ( items[ i ].dashed ? " is-dashed" : "" );
|
|
973
|
+
item.appendChild( swatch );
|
|
974
|
+
const text = document.createElement( "span" );
|
|
975
|
+
text.textContent = items[ i ].label || "";
|
|
976
|
+
item.appendChild( text );
|
|
977
|
+
wrap.appendChild( item );
|
|
978
|
+
}
|
|
979
|
+
return wrap;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Grouped horizontal bars: per row, one sub-bar per value, all widths on one shared global max so rows compare.
|
|
984
|
+
* A swatch legend is rendered below the chart when spec.options.legend is provided, and a per-bar value caption
|
|
985
|
+
* (formatNumber, 2dp) is drawn at each bar's end when spec.options.valueLabels is set.
|
|
986
|
+
*/
|
|
987
|
+
function _renderBarsGrouped( figure, spec ) {
|
|
988
|
+
const data = spec.data;
|
|
989
|
+
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
990
|
+
if ( rows.length === 0 ) {
|
|
991
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const valueLabels = !!( spec.options && spec.options.valueLabels );
|
|
995
|
+
// Optional bar height + value-caption font (viewBox units) — default to the layout's own 4 / the CSS size.
|
|
996
|
+
const barThickness = ( spec.options && typeof spec.options.barThickness === "number" ) ? spec.options.barThickness : null;
|
|
997
|
+
const valueFontSize = ( spec.options && typeof spec.options.valueFontSize === "number" ) ? spec.options.valueFontSize : null;
|
|
998
|
+
// Reserve a right gutter for the value caption so it never spills past the 100-wide viewBox.
|
|
999
|
+
const trackW = valueLabels ? 86 : 100, rowGap = 6, labelH = 5, padTop = 4;
|
|
1000
|
+
const layoutOpts = { trackW: trackW };
|
|
1001
|
+
if ( barThickness !== null ) { layoutOpts.barH = barThickness; }
|
|
1002
|
+
const layout = barsGroupedLayout( rows, layoutOpts );
|
|
1003
|
+
let height = padTop;
|
|
1004
|
+
for ( let i = 0; i < layout.rows.length; i++ ) {
|
|
1005
|
+
height += labelH + layout.rows[ i ].rowHeight + rowGap;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const svg = svgEl( "svg", { viewBox: "0 0 100 " + _round( height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1009
|
+
_appendA11yTitle( svg, spec );
|
|
1010
|
+
|
|
1011
|
+
const srRows = [];
|
|
1012
|
+
let cursorY = padTop;
|
|
1013
|
+
for ( let r = 0; r < layout.rows.length; r++ ) {
|
|
1014
|
+
const row = layout.rows[ r ];
|
|
1015
|
+
const lbl = svgEl( "text", { x: 0, y: _round( cursorY + 3.5 ), class: "ti-chart-bar-label" } );
|
|
1016
|
+
lbl.textContent = row.label;
|
|
1017
|
+
svg.appendChild( lbl );
|
|
1018
|
+
const barsTop = cursorY + labelH;
|
|
1019
|
+
for ( let b = 0; b < row.bars.length; b++ ) {
|
|
1020
|
+
const bar = row.bars[ b ];
|
|
1021
|
+
let cls = "ti-chart-bar-seg";
|
|
1022
|
+
if ( bar.tone ) {
|
|
1023
|
+
cls = cls + " tone-" + bar.tone;
|
|
1024
|
+
}
|
|
1025
|
+
const rect = svgEl( "rect", { x: 0, y: _round( barsTop + bar.subY ), width: bar.width, height: bar.height, rx: 1, class: cls } );
|
|
1026
|
+
if ( spec.provisional ) {
|
|
1027
|
+
rect.setAttribute( "opacity", "0.7" );
|
|
1028
|
+
}
|
|
1029
|
+
svg.appendChild( rect );
|
|
1030
|
+
if ( valueLabels ) {
|
|
1031
|
+
// The caption uses .ti-chart-bar-value (fill only — NO font-size in CSS), so the font-size
|
|
1032
|
+
// presentation attribute actually governs; a CSS font-size rule (e.g. .ti-chart-bar-label) would
|
|
1033
|
+
// otherwise win over it. Default 4 matches the prior caption size.
|
|
1034
|
+
const vt = svgEl( "text", {
|
|
1035
|
+
x: _round( bar.width + 1.5 ),
|
|
1036
|
+
y: _round( barsTop + bar.subY + ( bar.height / 2 ) ),
|
|
1037
|
+
class: "ti-chart-bar-value",
|
|
1038
|
+
"font-size": ( valueFontSize !== null ) ? valueFontSize : 4,
|
|
1039
|
+
"dominant-baseline": "central"
|
|
1040
|
+
} );
|
|
1041
|
+
vt.textContent = formatNumber( bar.v, 2 );
|
|
1042
|
+
svg.appendChild( vt );
|
|
1043
|
+
}
|
|
1044
|
+
srRows.push( [ row.label, bar.key, String( bar.v ) ] );
|
|
1045
|
+
}
|
|
1046
|
+
cursorY = barsTop + row.rowHeight + rowGap;
|
|
1047
|
+
}
|
|
1048
|
+
figure.appendChild( svg );
|
|
1049
|
+
if ( spec.options && Array.isArray( spec.options.legend ) && spec.options.legend.length > 0 ) {
|
|
1050
|
+
figure.appendChild( _buildChartLegend( spec.options.legend ) );
|
|
1051
|
+
}
|
|
1052
|
+
figure.appendChild( buildSrTable( [ "Row", "Series", "Value" ], srRows ) );
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Diverging horizontal bars centered on zero: per row, one bar per signed value; positive extends right, negative
|
|
1057
|
+
* left, on one shared max-abs. A center axis line marks zero.
|
|
1058
|
+
*/
|
|
1059
|
+
function _renderBarsDiverging( figure, spec ) {
|
|
1060
|
+
const data = spec.data;
|
|
1061
|
+
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
1062
|
+
if ( rows.length === 0 ) {
|
|
1063
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
// Landscape viewBox (width 200) so a 9-row diverging chart reads wide, not portrait; the bar math scales to trackW.
|
|
1067
|
+
const trackW = 200, rowH = 8, gap = 3, labelH = 5, padTop = 4;
|
|
1068
|
+
const layout = barsDivergingLayout( rows, { trackW: trackW } );
|
|
1069
|
+
const bandH = labelH + rowH + gap;
|
|
1070
|
+
const height = padTop + ( layout.rows.length * bandH );
|
|
1071
|
+
|
|
1072
|
+
const svg = svgEl( "svg", { viewBox: "0 0 200 " + _round( height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1073
|
+
_appendA11yTitle( svg, spec );
|
|
1074
|
+
svg.appendChild( svgEl( "line", { x1: layout.center, y1: padTop, x2: layout.center, y2: _round( height ), class: "ti-chart-bar-axis" } ) );
|
|
1075
|
+
|
|
1076
|
+
const srRows = [];
|
|
1077
|
+
for ( let r = 0; r < layout.rows.length; r++ ) {
|
|
1078
|
+
const row = layout.rows[ r ];
|
|
1079
|
+
const bandTop = padTop + ( r * bandH );
|
|
1080
|
+
const lbl = svgEl( "text", { x: 0, y: _round( bandTop + 3.5 ), class: "ti-chart-bar-label" } );
|
|
1081
|
+
lbl.textContent = row.label;
|
|
1082
|
+
svg.appendChild( lbl );
|
|
1083
|
+
const barsTop = bandTop + labelH;
|
|
1084
|
+
const sub = Math.max( 1, row.bars.length );
|
|
1085
|
+
const subH = rowH / sub;
|
|
1086
|
+
for ( let b = 0; b < row.bars.length; b++ ) {
|
|
1087
|
+
const bar = row.bars[ b ];
|
|
1088
|
+
let cls = "ti-chart-bar-seg";
|
|
1089
|
+
if ( bar.tone ) {
|
|
1090
|
+
cls = cls + " tone-" + bar.tone;
|
|
1091
|
+
}
|
|
1092
|
+
const rect = svgEl( "rect", {
|
|
1093
|
+
x: bar.x,
|
|
1094
|
+
y: _round( barsTop + ( b * subH ) ),
|
|
1095
|
+
width: bar.width,
|
|
1096
|
+
height: _round( subH * 0.9 ),
|
|
1097
|
+
rx: 0.5,
|
|
1098
|
+
class: cls
|
|
1099
|
+
} );
|
|
1100
|
+
if ( spec.provisional ) {
|
|
1101
|
+
rect.setAttribute( "opacity", "0.7" );
|
|
1102
|
+
}
|
|
1103
|
+
svg.appendChild( rect );
|
|
1104
|
+
srRows.push( [ row.label, bar.key, String( bar.v ) ] );
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
figure.appendChild( svg );
|
|
1108
|
+
figure.appendChild( buildSrTable( [ "Row", "Series", "Gap" ], srRows ) );
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Renders a KPI stat tile (HTML+SVG-free). value/label/sub plus an optional pct mini-bar whose
|
|
1113
|
+
* fill width rides as a CSS var via setProperty (the sanctioned style exception).
|
|
1114
|
+
* @param {Element} figure
|
|
1115
|
+
* @param {TiChartSpec} spec
|
|
1116
|
+
*/
|
|
1117
|
+
function renderStat( figure, spec ) {
|
|
1118
|
+
const data = spec.data;
|
|
1119
|
+
const doc = ( typeof document !== "undefined" ) ? document : null;
|
|
1120
|
+
const wrap = doc.createElement( "div" );
|
|
1121
|
+
wrap.setAttribute( "class", "ti-chart-stat" );
|
|
1122
|
+
const valueEl = doc.createElement( "div" );
|
|
1123
|
+
valueEl.setAttribute( "class", "ti-chart-stat-value tabular-nums" );
|
|
1124
|
+
const hasValue = ( typeof data.value === "number" && Number.isFinite( data.value ) );
|
|
1125
|
+
valueEl.textContent = hasValue ? formatNumber( data.value ) : "—";
|
|
1126
|
+
wrap.appendChild( valueEl );
|
|
1127
|
+
if ( data.label ) {
|
|
1128
|
+
const l = doc.createElement( "div" );
|
|
1129
|
+
l.setAttribute( "class", "ti-chart-stat-label" );
|
|
1130
|
+
l.textContent = data.label;
|
|
1131
|
+
wrap.appendChild( l );
|
|
1132
|
+
}
|
|
1133
|
+
if ( data.sub ) {
|
|
1134
|
+
const sub = doc.createElement( "div" );
|
|
1135
|
+
sub.setAttribute( "class", "ti-chart-stat-sub" );
|
|
1136
|
+
sub.textContent = data.sub;
|
|
1137
|
+
wrap.appendChild( sub );
|
|
1138
|
+
}
|
|
1139
|
+
if ( typeof data.pct === "number" ) {
|
|
1140
|
+
const bar = doc.createElement( "div" );
|
|
1141
|
+
bar.setAttribute( "class", "ti-chart-stat-bar" );
|
|
1142
|
+
const fill = doc.createElement( "div" );
|
|
1143
|
+
fill.setAttribute( "class", "ti-chart-stat-fill" );
|
|
1144
|
+
fill.style.setProperty( "--pct", formatPercent( data.pct ) ); // sanctioned --var exception
|
|
1145
|
+
bar.appendChild( fill );
|
|
1146
|
+
wrap.appendChild( bar );
|
|
1147
|
+
}
|
|
1148
|
+
figure.appendChild( wrap );
|
|
1149
|
+
figure.appendChild( buildSrTable( [ "Metric", "Value" ], [ [ data.label || spec.a11yLabel, ( hasValue ? formatNumber( data.value ) : "—" ) ] ] ) );
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Renders a scatter plot (R3 alignment quadrant): optional quadrant midlines + y=x diagonal, then one circle per
|
|
1154
|
+
* point (radius from z when options.bubble==="z"). Points are drill-interactive unless options.anonymize.
|
|
1155
|
+
* @param {Element} figure
|
|
1156
|
+
* @param {TiChartSpec} spec
|
|
1157
|
+
*/
|
|
1158
|
+
function renderScatter( figure, spec ) {
|
|
1159
|
+
const data = spec.data;
|
|
1160
|
+
const points = Array.isArray( data.points ) ? data.points : [];
|
|
1161
|
+
const opts = Object.assign( {}, spec.options, { diagonal: !!data.diagonal } );
|
|
1162
|
+
const anonymize = !!( spec.options && spec.options.anonymize );
|
|
1163
|
+
const layout = scatterLayout( points, opts );
|
|
1164
|
+
|
|
1165
|
+
const svg = svgEl( "svg", { viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1166
|
+
_appendA11yTitle( svg, spec );
|
|
1167
|
+
|
|
1168
|
+
if ( layout.midX ) {
|
|
1169
|
+
svg.appendChild( svgEl( "line", { x1: layout.midX.x, y1: layout.midX.y1, x2: layout.midX.x, y2: layout.midX.y2, class: "ti-chart-scatter-mid" } ) );
|
|
1170
|
+
}
|
|
1171
|
+
if ( layout.midY ) {
|
|
1172
|
+
svg.appendChild( svgEl( "line", { x1: layout.midY.x1, y1: layout.midY.y, x2: layout.midY.x2, y2: layout.midY.y, class: "ti-chart-scatter-mid" } ) );
|
|
1173
|
+
}
|
|
1174
|
+
if ( layout.diagonal ) {
|
|
1175
|
+
svg.appendChild( svgEl( "line", {
|
|
1176
|
+
x1: layout.diagonal.x1,
|
|
1177
|
+
y1: layout.diagonal.y1,
|
|
1178
|
+
x2: layout.diagonal.x2,
|
|
1179
|
+
y2: layout.diagonal.y2,
|
|
1180
|
+
class: "ti-chart-scatter-diag"
|
|
1181
|
+
} ) );
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const srRows = [];
|
|
1185
|
+
for ( let i = 0; i < layout.points.length; i++ ) {
|
|
1186
|
+
const p = layout.points[ i ];
|
|
1187
|
+
let cls = "ti-chart-scatter-pt";
|
|
1188
|
+
if ( p.tone ) {
|
|
1189
|
+
cls = cls + " tone-" + p.tone;
|
|
1190
|
+
}
|
|
1191
|
+
if ( spec.provisional ) {
|
|
1192
|
+
cls = cls + " ti-chart-provisional";
|
|
1193
|
+
}
|
|
1194
|
+
const circle = svgEl( "circle", { cx: p.cx, cy: p.cy, r: p.r, class: cls } );
|
|
1195
|
+
if ( !anonymize ) {
|
|
1196
|
+
_attachSelect( circle, { id: p.id, label: p.label } );
|
|
1197
|
+
if ( p.label ) {
|
|
1198
|
+
const t = svgEl( "title", {} );
|
|
1199
|
+
t.textContent = String( p.label );
|
|
1200
|
+
circle.appendChild( t );
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
svg.appendChild( circle );
|
|
1204
|
+
srRows.push( [ anonymize ? "" : ( ( p.label !== null && p.label !== undefined ) ? String( p.label ) : String( p.id || "" ) ), formatNumber( p.x, 2 ), formatNumber( p.y, 2 ), ( p.z !== null ) ? formatNumber( p.z, 2 ) : "—" ] );
|
|
1205
|
+
}
|
|
1206
|
+
figure.appendChild( svg );
|
|
1207
|
+
figure.appendChild( buildSrTable( [ "Point", "Manager", "Self", "Team" ], srRows ) );
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Renders a heatmap (R4): a grid of cells colored by a sequential quantile ramp (cell-q1..5) or a diverging
|
|
1212
|
+
* grade scale (cell-pos/neg with magnitude as the opacity presentation attribute), plus row/column labels.
|
|
1213
|
+
* @param {Element} figure
|
|
1214
|
+
* @param {TiChartSpec} spec
|
|
1215
|
+
*/
|
|
1216
|
+
function renderHeatmap( figure, spec ) {
|
|
1217
|
+
const data = spec.data;
|
|
1218
|
+
const rows = Array.isArray( data.rows ) ? data.rows : [];
|
|
1219
|
+
const cols = Array.isArray( data.cols ) ? data.cols : [];
|
|
1220
|
+
const cells = Array.isArray( data.cells ) ? data.cells : [];
|
|
1221
|
+
if ( rows.length === 0 || cols.length === 0 ) {
|
|
1222
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const scale = ( spec.options && spec.options.scale === "diverging" ) ? "diverging" : "sequential";
|
|
1226
|
+
const layout = heatmapLayout( rows, cols, cells, Object.assign( {}, spec.options, { scale: scale } ) );
|
|
1227
|
+
|
|
1228
|
+
const svg = svgEl( "svg", { viewBox: "0 0 100 " + _round( layout.height ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1229
|
+
_appendA11yTitle( svg, spec );
|
|
1230
|
+
|
|
1231
|
+
const _heatRowLabels = rows.map( ( r ) => r.label || r.id );
|
|
1232
|
+
const _heatColLabels = cols.map( ( c ) => c.label || c.id );
|
|
1233
|
+
for ( let i = 0; i < layout.cells.length; i++ ) {
|
|
1234
|
+
const cell = layout.cells[ i ];
|
|
1235
|
+
let cls = "ti-chart-heat-cell";
|
|
1236
|
+
if ( cell.suppressed ) {
|
|
1237
|
+
cls = cls + " suppressed";
|
|
1238
|
+
} else if ( scale === "sequential" ) {
|
|
1239
|
+
cls = cls + " cell-q" + cell.bucket;
|
|
1240
|
+
} else {
|
|
1241
|
+
cls = cls + " cell-" + cell.sign;
|
|
1242
|
+
}
|
|
1243
|
+
const rect = svgEl( "rect", { x: cell.x, y: cell.y, width: cell.w, height: cell.h, class: cls } );
|
|
1244
|
+
if ( scale === "diverging" && !cell.suppressed && cell.sign !== "zero" ) {
|
|
1245
|
+
rect.setAttribute( "opacity", String( _round( 0.25 + ( 0.75 * cell.mag ) ) ) );
|
|
1246
|
+
}
|
|
1247
|
+
if ( !cell.suppressed ) {
|
|
1248
|
+
const cellV = ( scale === "diverging" ) ? formatNumber( cell.delta, 2 ) : formatNumber( cell.v, 2 );
|
|
1249
|
+
const cellLabel = String( _heatRowLabels[ cell.r ] || cell.r ) + " / " + String( _heatColLabels[ cell.c ] || cell.c ) + ": " + cellV;
|
|
1250
|
+
_attachSelect( rect, { r: cell.r, c: cell.c }, cellLabel );
|
|
1251
|
+
}
|
|
1252
|
+
svg.appendChild( rect );
|
|
1253
|
+
}
|
|
1254
|
+
for ( let i = 0; i < layout.rowLabels.length; i++ ) {
|
|
1255
|
+
const rl = layout.rowLabels[ i ];
|
|
1256
|
+
const t = svgEl( "text", { x: rl.x, y: rl.y, class: "ti-chart-heat-label", "text-anchor": "end", "dominant-baseline": "central" } );
|
|
1257
|
+
t.textContent = String( rl.label );
|
|
1258
|
+
svg.appendChild( t );
|
|
1259
|
+
}
|
|
1260
|
+
for ( let i = 0; i < layout.colLabels.length; i++ ) {
|
|
1261
|
+
const cl = layout.colLabels[ i ];
|
|
1262
|
+
const t = svgEl( "text", { x: cl.x, y: cl.y, class: "ti-chart-heat-label", "text-anchor": "middle" } );
|
|
1263
|
+
t.textContent = String( cl.label );
|
|
1264
|
+
svg.appendChild( t );
|
|
1265
|
+
}
|
|
1266
|
+
figure.appendChild( svg );
|
|
1267
|
+
|
|
1268
|
+
const srRows = [];
|
|
1269
|
+
const colByIndex = cols.map( ( c ) => c.label || c.id );
|
|
1270
|
+
const rowByIndex = rows.map( ( r ) => r.label || r.id );
|
|
1271
|
+
for ( let i = 0; i < layout.cells.length; i++ ) {
|
|
1272
|
+
const cell = layout.cells[ i ];
|
|
1273
|
+
const v = cell.suppressed ? "n<min" : ( ( scale === "diverging" ) ? formatNumber( cell.delta, 2 ) : formatNumber( cell.v, 2 ) );
|
|
1274
|
+
srRows.push( [ String( rowByIndex[ cell.r ] || cell.r ), String( colByIndex[ cell.c ] || cell.c ), v ] );
|
|
1275
|
+
}
|
|
1276
|
+
figure.appendChild( buildSrTable( [ "Row", "Column", ( scale === "diverging" ) ? "Gap" : "Value" ], srRows ) );
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Renders box-plots (R5 level correlation): one box per group (q1..q3 with median line + min/max whiskers), an
|
|
1281
|
+
* optional dashed expected marker and mean dot, plus global reference line(s).
|
|
1282
|
+
* @param {Element} figure
|
|
1283
|
+
* @param {TiChartSpec} spec
|
|
1284
|
+
*/
|
|
1285
|
+
function renderBox( figure, spec ) {
|
|
1286
|
+
const data = spec.data;
|
|
1287
|
+
const groups = Array.isArray( data.groups ) ? data.groups : [];
|
|
1288
|
+
if ( groups.length === 0 ) {
|
|
1289
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const layout = boxLayout( groups, Object.assign( { reference: data.reference }, spec.options ) );
|
|
1293
|
+
|
|
1294
|
+
const svg = svgEl( "svg", {
|
|
1295
|
+
viewBox: "0 0 " + _round( layout.width ) + " " + _round( layout.height ),
|
|
1296
|
+
preserveAspectRatio: "xMidYMid meet",
|
|
1297
|
+
role: "img"
|
|
1298
|
+
} );
|
|
1299
|
+
_appendA11yTitle( svg, spec );
|
|
1300
|
+
|
|
1301
|
+
// global reference lines (e.g. T3)
|
|
1302
|
+
for ( let i = 0; i < layout.refs.length; i++ ) {
|
|
1303
|
+
const ref = layout.refs[ i ];
|
|
1304
|
+
svg.appendChild( svgEl( "line", { x1: ref.x1, y1: ref.y, x2: ref.x2, y2: ref.y, class: "ti-chart-box-ref" } ) );
|
|
1305
|
+
if ( ref.label ) {
|
|
1306
|
+
const t = svgEl( "text", { x: ref.x2, y: _round( ref.y - 0.5 ), class: "ti-chart-box-ref-label", "text-anchor": "end" } );
|
|
1307
|
+
t.textContent = String( ref.label );
|
|
1308
|
+
svg.appendChild( t );
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const srRows = [];
|
|
1313
|
+
for ( let i = 0; i < layout.boxes.length; i++ ) {
|
|
1314
|
+
const box = layout.boxes[ i ];
|
|
1315
|
+
const lbl = svgEl( "text", { x: box.cx, y: _round( layout.axis.plotBottom + 6 ), class: "ti-chart-box-label", "text-anchor": "middle" } );
|
|
1316
|
+
lbl.textContent = String( box.label );
|
|
1317
|
+
svg.appendChild( lbl );
|
|
1318
|
+
if ( box.suppressed ) {
|
|
1319
|
+
srRows.push( [ String( box.label ), "n<min", "", "", "", "", String( box.n ) ] );
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
// whisker (min..max) + caps
|
|
1323
|
+
svg.appendChild( svgEl( "line", { x1: box.cx, y1: box.yMax, x2: box.cx, y2: box.yMin, class: "ti-chart-box-whisker" } ) );
|
|
1324
|
+
svg.appendChild( svgEl( "line", {
|
|
1325
|
+
x1: _round( box.cx - ( box.w / 3 ) ),
|
|
1326
|
+
y1: box.yMax,
|
|
1327
|
+
x2: _round( box.cx + ( box.w / 3 ) ),
|
|
1328
|
+
y2: box.yMax,
|
|
1329
|
+
class: "ti-chart-box-cap"
|
|
1330
|
+
} ) );
|
|
1331
|
+
svg.appendChild( svgEl( "line", {
|
|
1332
|
+
x1: _round( box.cx - ( box.w / 3 ) ),
|
|
1333
|
+
y1: box.yMin,
|
|
1334
|
+
x2: _round( box.cx + ( box.w / 3 ) ),
|
|
1335
|
+
y2: box.yMin,
|
|
1336
|
+
class: "ti-chart-box-cap"
|
|
1337
|
+
} ) );
|
|
1338
|
+
// box (q1..q3) — note yQ3 (higher score) is the smaller pixel, so it is the top
|
|
1339
|
+
const boxRect = svgEl( "rect", { x: box.x, y: box.yQ3, width: box.w, height: _round( box.yQ1 - box.yQ3 ), class: "ti-chart-box-box" } );
|
|
1340
|
+
const boxLabel = String( box.label ) + ": " + formatNumber( box.q1 ) + "–" + formatNumber( box.q3 ) + " med " + formatNumber( box.median );
|
|
1341
|
+
_attachSelect( boxRect, { id: box.id }, boxLabel );
|
|
1342
|
+
svg.appendChild( boxRect );
|
|
1343
|
+
svg.appendChild( svgEl( "line", { x1: box.x, y1: box.yMed, x2: _round( box.x + box.w ), y2: box.yMed, class: "ti-chart-box-median" } ) );
|
|
1344
|
+
if ( box.yExpected !== null ) {
|
|
1345
|
+
svg.appendChild( svgEl( "line", {
|
|
1346
|
+
x1: _round( box.cx - ( box.w / 2 ) ),
|
|
1347
|
+
y1: box.yExpected,
|
|
1348
|
+
x2: _round( box.cx + ( box.w / 2 ) ),
|
|
1349
|
+
y2: box.yExpected,
|
|
1350
|
+
class: "ti-chart-box-expected"
|
|
1351
|
+
} ) );
|
|
1352
|
+
}
|
|
1353
|
+
if ( box.yMean !== null ) {
|
|
1354
|
+
svg.appendChild( svgEl( "circle", { cx: box.cx, cy: box.yMean, r: 0.9, class: "ti-chart-box-mean" } ) );
|
|
1355
|
+
}
|
|
1356
|
+
srRows.push( [ String( box.label ), formatNumber( box.min ), formatNumber( box.q1 ), formatNumber( box.median ), formatNumber( box.q3 ), formatNumber( box.max ), String( box.n ) ] );
|
|
1357
|
+
}
|
|
1358
|
+
figure.appendChild( svg );
|
|
1359
|
+
figure.appendChild( buildSrTable( [ "Level", "Min", "Q1", "Median", "Q3", "Max", "N" ], srRows ) );
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Renders a radar/spider chart (the individual 9-subcategory profile): concentric polygon rings, axis spokes +
|
|
1364
|
+
* labels, one filled polygon per series (self/manager/team), and an optional dashed "expected" series. All geometry
|
|
1365
|
+
* via radarLayout + setAttribute; an "expected"/provisional series draws unfilled with stroke-dasharray.
|
|
1366
|
+
* @param {Element} figure
|
|
1367
|
+
* @param {TiChartSpec} spec
|
|
1368
|
+
*/
|
|
1369
|
+
function renderRadar( figure, spec ) {
|
|
1370
|
+
const data = spec.data;
|
|
1371
|
+
const axes = Array.isArray( data.axes ) ? data.axes : [];
|
|
1372
|
+
const series = Array.isArray( data.series ) ? data.series : [];
|
|
1373
|
+
if ( axes.length === 0 ) {
|
|
1374
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const layout = radarLayout( axes, series, spec.options || {} );
|
|
1378
|
+
|
|
1379
|
+
const svg = svgEl( "svg", { viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1380
|
+
_appendA11yTitle( svg, spec );
|
|
1381
|
+
|
|
1382
|
+
for ( let i = 0; i < layout.rings.length; i++ ) {
|
|
1383
|
+
svg.appendChild( svgEl( "polygon", { points: layout.rings[ i ].points, class: "ti-chart-radar-ring", fill: "none" } ) );
|
|
1384
|
+
}
|
|
1385
|
+
for ( let i = 0; i < layout.axes.length; i++ ) {
|
|
1386
|
+
const ax = layout.axes[ i ];
|
|
1387
|
+
svg.appendChild( svgEl( "line", { x1: layout.cx, y1: layout.cy, x2: ax.outerX, y2: ax.outerY, class: "ti-chart-radar-spoke" } ) );
|
|
1388
|
+
const t = svgEl( "text", {
|
|
1389
|
+
x: ax.labelX,
|
|
1390
|
+
y: ax.labelY,
|
|
1391
|
+
class: "ti-chart-radar-axis-label" + ( ax.tone ? ( " tone-" + ax.tone ) : "" ),
|
|
1392
|
+
"text-anchor": "middle",
|
|
1393
|
+
"dominant-baseline": "central"
|
|
1394
|
+
} );
|
|
1395
|
+
t.textContent = String( ax.label );
|
|
1396
|
+
svg.appendChild( t );
|
|
1397
|
+
}
|
|
1398
|
+
for ( let i = 0; i < layout.series.length; i++ ) {
|
|
1399
|
+
const s = layout.series[ i ];
|
|
1400
|
+
let cls = "ti-chart-radar-poly";
|
|
1401
|
+
if ( s.tone ) {
|
|
1402
|
+
cls = cls + " tone-" + s.tone;
|
|
1403
|
+
}
|
|
1404
|
+
const poly = svgEl( "polygon", { points: s.points, class: cls } );
|
|
1405
|
+
if ( s.style === "dashed" || spec.provisional ) {
|
|
1406
|
+
poly.setAttribute( "fill", "none" );
|
|
1407
|
+
poly.setAttribute( "stroke-dasharray", "3 2" );
|
|
1408
|
+
}
|
|
1409
|
+
svg.appendChild( poly );
|
|
1410
|
+
for ( let d = 0; d < s.dots.length; d++ ) {
|
|
1411
|
+
let dotCls = "ti-chart-radar-dot";
|
|
1412
|
+
if ( s.tone ) {
|
|
1413
|
+
dotCls = dotCls + " tone-" + s.tone;
|
|
1414
|
+
}
|
|
1415
|
+
svg.appendChild( svgEl( "circle", { cx: s.dots[ d ].x, cy: s.dots[ d ].y, r: 0.9, class: dotCls } ) );
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
figure.appendChild( svg );
|
|
1419
|
+
if ( spec.options && Array.isArray( spec.options.legend ) && spec.options.legend.length > 0 ) {
|
|
1420
|
+
figure.appendChild( _buildChartLegend( spec.options.legend ) );
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const headers = [ "Axis" ].concat( layout.series.map( ( s ) => s.key ) );
|
|
1424
|
+
const srRows = layout.axes.map( ( ax ) => {
|
|
1425
|
+
const row = [ String( ax.label ) ];
|
|
1426
|
+
for ( let i = 0; i < layout.series.length; i++ ) {
|
|
1427
|
+
const dot = layout.series[ i ].dots.find( ( d ) => d.axisId === ax.id );
|
|
1428
|
+
row.push( dot ? formatNumber( dot.value, 2 ) : "—" );
|
|
1429
|
+
}
|
|
1430
|
+
return row;
|
|
1431
|
+
} );
|
|
1432
|
+
figure.appendChild( buildSrTable( headers, srRows ) );
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Renders the cross-cycle line/trend primitive (CA-X1): an optional baseline axis, one filled band area per series
|
|
1437
|
+
* (p25–p75), a polyline per contiguous segment (null gaps break the line), vertex dots, x-axis cycle labels, and an
|
|
1438
|
+
* sr-table. A `style:"dashed"` series (or spec.provisional) draws dashed; `options.provisionalLastPoint` dashes just
|
|
1439
|
+
* the final connector of the primary series (the live ACTIVE cycle still in flight) and marks its last dot. Sparkline
|
|
1440
|
+
* mode drops the axis/labels. All geometry via lineLayout + setAttribute (no element.style).
|
|
1441
|
+
* @param {Element} figure
|
|
1442
|
+
* @param {TiChartSpec} spec
|
|
1443
|
+
*/
|
|
1444
|
+
function renderLine( figure, spec ) {
|
|
1445
|
+
const data = spec.data;
|
|
1446
|
+
const x = Array.isArray( data.x ) ? data.x : [];
|
|
1447
|
+
const series = Array.isArray( data.series ) ? data.series : [];
|
|
1448
|
+
if ( x.length === 0 || series.length === 0 ) {
|
|
1449
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const options = spec.options || {};
|
|
1453
|
+
const layout = lineLayout( series, Object.assign( {}, options, { xCount: x.length } ) );
|
|
1454
|
+
|
|
1455
|
+
const svg = svgEl( "svg", { viewBox: "0 0 " + _round( layout.W ) + " " + _round( layout.H ), preserveAspectRatio: "xMidYMid meet", role: "img" } );
|
|
1456
|
+
_appendA11yTitle( svg, spec );
|
|
1457
|
+
|
|
1458
|
+
if ( !layout.sparkline ) {
|
|
1459
|
+
const axisY = _round( layout.padT + layout.innerH );
|
|
1460
|
+
svg.appendChild( svgEl( "line", { x1: layout.padL, y1: axisY, x2: _round( layout.W - layout.padR ), y2: axisY, class: "ti-chart-line-axis" } ) );
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const provisionalLast = Boolean( options.provisionalLastPoint );
|
|
1464
|
+
|
|
1465
|
+
for ( let i = 0; i < layout.series.length; i++ ) {
|
|
1466
|
+
const s = layout.series[ i ];
|
|
1467
|
+
const tone = s.tone ? ( " tone-" + s.tone ) : "";
|
|
1468
|
+
const dashed = ( s.style === "dashed" || spec.provisional );
|
|
1469
|
+
if ( s.band ) {
|
|
1470
|
+
svg.appendChild( svgEl( "polygon", { points: s.band, class: "ti-chart-line-band" + tone } ) );
|
|
1471
|
+
}
|
|
1472
|
+
for ( let g = 0; g < s.segments.length; g++ ) {
|
|
1473
|
+
const pts = s.segments[ g ].split( " " );
|
|
1474
|
+
if ( provisionalLast && i === 0 && g === s.segments.length - 1 && pts.length >= 2 ) {
|
|
1475
|
+
// split the final connector as a dashed "provisional" segment (active cycle still in flight)
|
|
1476
|
+
const solid = pts.slice( 0, pts.length - 1 );
|
|
1477
|
+
if ( solid.length >= 2 ) {
|
|
1478
|
+
svg.appendChild( svgEl( "polyline", { points: solid.join( " " ), class: "ti-chart-line-series" + tone, fill: "none" } ) );
|
|
1479
|
+
}
|
|
1480
|
+
const tail = svgEl( "polyline", { points: pts.slice( pts.length - 2 ).join( " " ), class: "ti-chart-line-series" + tone, fill: "none" } );
|
|
1481
|
+
tail.setAttribute( "stroke-dasharray", "3 2" );
|
|
1482
|
+
svg.appendChild( tail );
|
|
1483
|
+
} else {
|
|
1484
|
+
const pl = svgEl( "polyline", { points: s.segments[ g ], class: "ti-chart-line-series" + tone, fill: "none" } );
|
|
1485
|
+
if ( dashed ) {
|
|
1486
|
+
pl.setAttribute( "stroke-dasharray", "3 2" );
|
|
1487
|
+
}
|
|
1488
|
+
svg.appendChild( pl );
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
for ( let d = 0; d < s.dots.length; d++ ) {
|
|
1492
|
+
const dot = s.dots[ d ];
|
|
1493
|
+
let dotCls = "ti-chart-line-dot" + tone;
|
|
1494
|
+
if ( provisionalLast && i === 0 && dot.xIndex === layout.n - 1 ) {
|
|
1495
|
+
dotCls += " provisional";
|
|
1496
|
+
}
|
|
1497
|
+
svg.appendChild( svgEl( "circle", { cx: dot.x, cy: dot.y, r: layout.sparkline ? 0.8 : 1.1, class: dotCls } ) );
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if ( !layout.sparkline ) {
|
|
1502
|
+
for ( let i = 0; i < x.length; i++ ) {
|
|
1503
|
+
const lx = ( layout.n <= 1 ) ? _round( layout.padL + ( layout.innerW / 2 ) ) : _round( layout.padL + ( ( layout.innerW * i ) / ( layout.n - 1 ) ) );
|
|
1504
|
+
const t = svgEl( "text", { x: lx, y: _round( layout.H - 2 ), class: "ti-chart-line-xlabel", "text-anchor": "middle" } );
|
|
1505
|
+
t.textContent = String( ( x[ i ].label !== undefined ) ? x[ i ].label : x[ i ].id );
|
|
1506
|
+
svg.appendChild( t );
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
figure.appendChild( svg );
|
|
1511
|
+
|
|
1512
|
+
const headers = [ "Cycle" ].concat( series.map( ( s ) => s.key ) );
|
|
1513
|
+
const srRows = x.map( ( xi, i ) => {
|
|
1514
|
+
const row = [ String( ( xi.label !== undefined ) ? xi.label : xi.id ) ];
|
|
1515
|
+
for ( let j = 0; j < series.length; j++ ) {
|
|
1516
|
+
const v = Array.isArray( series[ j ].values ) ? series[ j ].values[ i ] : null;
|
|
1517
|
+
row.push( ( typeof v === "number" ) ? formatNumber( v, 2 ) : "—" );
|
|
1518
|
+
}
|
|
1519
|
+
return row;
|
|
1520
|
+
} );
|
|
1521
|
+
figure.appendChild( buildSrTable( headers, srRows ) );
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Top-level dispatcher: clears the host figure, normalizes the spec, routes to a renderer.
|
|
1526
|
+
* @param {Element} figure host <figure class="ti-chart">
|
|
1527
|
+
* @param {*} rawSpec
|
|
1528
|
+
*/
|
|
1529
|
+
function renderChart( figure, rawSpec ) {
|
|
1530
|
+
if ( !figure ) {
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
const spec = normalizeSpec( rawSpec );
|
|
1534
|
+
_clearChildren( figure );
|
|
1535
|
+
figure.removeAttribute( "data-ti-chart-empty" );
|
|
1536
|
+
figure.removeAttribute( "aria-label" );
|
|
1537
|
+
figure.setAttribute( "role", "img" );
|
|
1538
|
+
figure.setAttribute( "data-ti-chart-type", spec.type ); // per-type sizing hook for CSS (cap + centering)
|
|
1539
|
+
if ( spec.a11yLabel ) {
|
|
1540
|
+
figure.setAttribute( "aria-label", spec.a11yLabel );
|
|
1541
|
+
}
|
|
1542
|
+
if ( spec.type === "gauge" ) {
|
|
1543
|
+
renderGauge( figure, spec );
|
|
1544
|
+
} else if ( spec.type === "bars" ) {
|
|
1545
|
+
renderBars( figure, spec );
|
|
1546
|
+
} else if ( spec.type === "stat" ) {
|
|
1547
|
+
renderStat( figure, spec );
|
|
1548
|
+
} else if ( spec.type === "scatter" ) {
|
|
1549
|
+
renderScatter( figure, spec );
|
|
1550
|
+
} else if ( spec.type === "heatmap" ) {
|
|
1551
|
+
renderHeatmap( figure, spec );
|
|
1552
|
+
} else if ( spec.type === "box" ) {
|
|
1553
|
+
renderBox( figure, spec );
|
|
1554
|
+
} else if ( spec.type === "radar" ) {
|
|
1555
|
+
renderRadar( figure, spec );
|
|
1556
|
+
} else if ( spec.type === "line" ) {
|
|
1557
|
+
renderLine( figure, spec );
|
|
1558
|
+
} else {
|
|
1559
|
+
figure.setAttribute( "data-ti-chart-empty", "1" );
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
return {
|
|
1564
|
+
SVG_NS,
|
|
1565
|
+
gaugeValueToAngle,
|
|
1566
|
+
gaugeArcPath,
|
|
1567
|
+
barSegments,
|
|
1568
|
+
normalizeSpec,
|
|
1569
|
+
gaugeRowsLayout,
|
|
1570
|
+
scatterLayout,
|
|
1571
|
+
quantileBucket,
|
|
1572
|
+
heatmapLayout,
|
|
1573
|
+
boxLayout,
|
|
1574
|
+
barsGroupedLayout,
|
|
1575
|
+
barsDivergingLayout,
|
|
1576
|
+
radarLayout,
|
|
1577
|
+
lineLayout,
|
|
1578
|
+
svgEl,
|
|
1579
|
+
buildSrTable,
|
|
1580
|
+
renderChart,
|
|
1581
|
+
formatPercent,
|
|
1582
|
+
formatNumber
|
|
1583
|
+
};
|
|
1584
|
+
} )();
|
|
1585
|
+
|
|
1586
|
+
if ( typeof module !== "undefined" && module.exports ) {
|
|
1587
|
+
module.exports = TiCharts;
|
|
1588
|
+
}
|
|
1589
|
+
if ( typeof window !== "undefined" ) {
|
|
1590
|
+
window.TiCharts = TiCharts;
|
|
1591
|
+
}
|