pict-section-activitygraph 1.0.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/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# pict-section-activitygraph
|
|
2
|
+
|
|
3
|
+
A Pict section that renders a stacked-bar activity timeline. Time runs top to
|
|
4
|
+
bottom, one row per period; each domain you configure is a column of horizontal
|
|
5
|
+
stacked bars (one bar per period, segmented by a stack dimension). Super-group
|
|
6
|
+
separators mark quarter boundaries (for day, week, and month granularities) or
|
|
7
|
+
year boundaries (for quarter). It is a hand-rolled themed SVG, so there is no
|
|
8
|
+
chart-library dependency and it re-themes live from the host's theme tokens.
|
|
9
|
+
|
|
10
|
+
Built for the Plansheet "System Activity" view: two columns (Work Items and
|
|
11
|
+
Knowledge), one bar per week stacked by activity type, with a switcher for the
|
|
12
|
+
granularity, the stack dimension, and the measure.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
npm install pict-section-activitygraph
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
Register the view with a host Pict application and point it at a DOM slot:
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
const libActivityGraph = require('pict-section-activitygraph');
|
|
26
|
+
|
|
27
|
+
pict.addView('MyActivityGraph', Object.assign({}, libActivityGraph.default_configuration,
|
|
28
|
+
{
|
|
29
|
+
ActivityGraph:
|
|
30
|
+
{
|
|
31
|
+
Period: 'Week',
|
|
32
|
+
Columns:
|
|
33
|
+
[
|
|
34
|
+
{ Key: 'WorkItems', Title: 'Work Items', Accent: '#e0891e' },
|
|
35
|
+
{ Key: 'Knowledge', Title: 'Knowledge', Accent: '#3f7fd0' }
|
|
36
|
+
],
|
|
37
|
+
// called when the user picks a new granularity, so the host can refetch that period
|
|
38
|
+
PeriodChangeHandler: (pPeriod, pView) => loadAndSetData(pPeriod, pView)
|
|
39
|
+
},
|
|
40
|
+
Renderables: [ { RenderableHash: 'Pict-ActivityGraph-Container', TemplateHash: 'Pict-ActivityGraph-Container', ContentDestinationAddress: '#My-Chart-Slot', RenderMethod: 'replace' } ]
|
|
41
|
+
}), libActivityGraph);
|
|
42
|
+
|
|
43
|
+
pict.views.MyActivityGraph.render();
|
|
44
|
+
pict.views.MyActivityGraph.setData({ Period: 'Week', Buckets: rows });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each bucket in `Buckets` is one aggregated cell:
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
{ PeriodStart: '2025-06-02', PeriodKey: '2025-W23', Domain: 'WorkItems',
|
|
51
|
+
ActivityType: 'Created', ActorKind: 'Human', EventCount: 3, Volume: 3 }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`Domain` matches a column `Key`. The stack dimension is `ActivityType` by
|
|
55
|
+
default or `ActorKind` (via the Stack toggle, or `setStackBy('ActorKind')`); the
|
|
56
|
+
measure is `EventCount` by default or `Volume` (`setMeasure('Volume')`).
|
|
57
|
+
|
|
58
|
+
## Renderer
|
|
59
|
+
|
|
60
|
+
The SVG builder is a pure function, exported for tests and headless
|
|
61
|
+
rasterization:
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
const libActivityGraph = require('pict-section-activitygraph');
|
|
65
|
+
const tmpSVG = libActivityGraph.Renderer.buildSVG({ Period: 'Week', Buckets: rows });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Test
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
npm test
|
|
72
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pict-section-activitygraph",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pict section that renders a stacked-bar activity timeline: one bar per period, stacked by type or actor, across parallel domains. Themed SVG, no chart dependency.",
|
|
5
|
+
"main": "source/Pict-Section-ActivityGraph.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node source/Pict-Section-ActivityGraph.js",
|
|
8
|
+
"test": "npx quack test",
|
|
9
|
+
"tests": "npx quack test -g",
|
|
10
|
+
"coverage": "npx quack coverage",
|
|
11
|
+
"build": "npx quack build"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/fable-retold/pict-section-activitygraph.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pict",
|
|
19
|
+
"pict-section",
|
|
20
|
+
"chart",
|
|
21
|
+
"activity",
|
|
22
|
+
"timeline",
|
|
23
|
+
"stacked-bar"
|
|
24
|
+
],
|
|
25
|
+
"author": "steven velozo <steven@velozo.com>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/fable-retold/pict-section-activitygraph/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/fable-retold/pict-section-activitygraph#readme",
|
|
31
|
+
"files": [
|
|
32
|
+
"source"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"pict-view": "^1.0.68"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"pict": "^1.0.392",
|
|
39
|
+
"quackage": "^1.3.0"
|
|
40
|
+
},
|
|
41
|
+
"mocha": {
|
|
42
|
+
"diff": true,
|
|
43
|
+
"extension": [
|
|
44
|
+
"js"
|
|
45
|
+
],
|
|
46
|
+
"package": "./package.json",
|
|
47
|
+
"reporter": "spec",
|
|
48
|
+
"slow": "75",
|
|
49
|
+
"timeout": "5000",
|
|
50
|
+
"ui": "tdd",
|
|
51
|
+
"watch-files": [
|
|
52
|
+
"source/**/*.js",
|
|
53
|
+
"test/**/*.js"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// CSS for pict-section-activitygraph. Theme-token driven so it re-themes live. The active control button
|
|
4
|
+
// pairs the brand fill with var(--theme-color-text-on-brand) rather than a hardcoded white, so it stays
|
|
5
|
+
// readable in themes whose brand is light (e.g. High Contrast dark, where brand is yellow).
|
|
6
|
+
module.exports = /*css*/`
|
|
7
|
+
.pict-activitygraph { font-family: -apple-system, Segoe UI, Roboto, sans-serif; color: var(--theme-color-text-primary, #2b2d2f); }
|
|
8
|
+
.pict-activitygraph .pag-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 14px; }
|
|
9
|
+
.pict-activitygraph .pag-group { display: inline-flex; align-items: center; gap: 4px; }
|
|
10
|
+
.pict-activitygraph .pag-group-right { margin-left: auto; }
|
|
11
|
+
.pict-activitygraph .pag-label { font-size: 11px; color: var(--theme-color-text-secondary, #6c6a60); text-transform: uppercase; letter-spacing: 0.04em; margin: 0 2px 0 10px; }
|
|
12
|
+
.pict-activitygraph .pag-btn { font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 6px; cursor: pointer; border: 1px solid var(--theme-color-border-default, #d8dde6); background: var(--theme-color-background-panel, #ffffff); color: var(--theme-color-text-secondary, #5b6376); }
|
|
13
|
+
.pict-activitygraph .pag-btn:hover { border-color: var(--theme-color-brand-primary, #3b6fd4); color: var(--theme-color-text-primary, #2c3140); }
|
|
14
|
+
.pict-activitygraph .pag-btn-on, .pict-activitygraph .pag-btn-on:hover { background: var(--theme-color-brand-primary, #3b6fd4); color: var(--theme-color-text-on-brand, #ffffff); border-color: transparent; }
|
|
15
|
+
.pict-activitygraph .pag-chart { width: 100%; overflow-x: auto; }
|
|
16
|
+
.pict-activitygraph .pag-chart svg { display: block; max-width: 100%; height: auto; }
|
|
17
|
+
.pict-activitygraph .pag-empty { color: var(--theme-color-text-secondary, #6c6a60); font-style: italic; font-size: 14px; padding: 32px 4px; }
|
|
18
|
+
`;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pict-ActivityGraph-Renderer
|
|
5
|
+
* ---------------------------
|
|
6
|
+
* Pure data -> SVG string. No DOM, no pict, no side effects, so it is trivially testable and can be
|
|
7
|
+
* rasterized headlessly. The view calls buildSVG(pModel); a test or a screenshot tool can call it too.
|
|
8
|
+
*
|
|
9
|
+
* The layout follows the System Activity whiteboard: time runs top to bottom (one row per period), and
|
|
10
|
+
* each configured domain is a column of horizontal stacked bars (one bar per period, segmented by the
|
|
11
|
+
* StackBy dimension). A separator with a label marks each super-group boundary (quarter for day/week/
|
|
12
|
+
* month granularities, year for quarter). Colors: theme CSS vars (with hex fallbacks) drive text,
|
|
13
|
+
* gridlines and surfaces so it re-themes live in the browser; the stack segments use a fixed categorical
|
|
14
|
+
* palette. Counts are drawn as theme-colored text at the bar ends -- never on the colored fills -- so the
|
|
15
|
+
* label contrast never depends on the segment color (the text-on-brand lesson).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Categorical palettes. Solid mid hues chosen to read on both light and dark panels. AI = violet to
|
|
19
|
+
// match the app's AI-actor convention.
|
|
20
|
+
const _TYPE_COLORS =
|
|
21
|
+
{
|
|
22
|
+
Created: '#2f9e6e',
|
|
23
|
+
Edited: '#3f7fd0',
|
|
24
|
+
StatusChange: '#d99a2b',
|
|
25
|
+
Completed: '#7a5cd0',
|
|
26
|
+
Commented: '#c85a9c'
|
|
27
|
+
};
|
|
28
|
+
const _ACTOR_COLORS =
|
|
29
|
+
{
|
|
30
|
+
Human: '#3f7fd0',
|
|
31
|
+
AI: '#7a5cd0',
|
|
32
|
+
System: '#8a93a6'
|
|
33
|
+
};
|
|
34
|
+
const _FALLBACK_SEQUENCE = ['#2f9e6e', '#3f7fd0', '#d99a2b', '#7a5cd0', '#c85a9c', '#4bb3b3', '#b3617a', '#8a93a6'];
|
|
35
|
+
|
|
36
|
+
// Theme tokens (var chain with a light-theme hex fallback). A headless rasterizer that does not resolve
|
|
37
|
+
// var() still gets the fallback if the caller strips `var(--x, ` / `)` down to the hex.
|
|
38
|
+
const _C =
|
|
39
|
+
{
|
|
40
|
+
text: 'var(--theme-color-text-primary, #2b2d2f)',
|
|
41
|
+
muted: 'var(--theme-color-text-secondary, #6c6a60)',
|
|
42
|
+
border: 'var(--theme-color-border-default, #d8dde6)',
|
|
43
|
+
panel: 'var(--theme-color-background-panel, #ffffff)',
|
|
44
|
+
track: 'var(--theme-color-background-tertiary, #eef1f6)'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function _esc(pValue)
|
|
48
|
+
{
|
|
49
|
+
return String(pValue == null ? '' : pValue).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _quarterOf(pDayString)
|
|
53
|
+
{
|
|
54
|
+
let tmpMonth = parseInt(String(pDayString || '').slice(5, 7), 10) || 1;
|
|
55
|
+
return Math.floor((tmpMonth - 1) / 3) + 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The super-group label a row belongs to, or null when the granularity needs no grouping band.
|
|
59
|
+
function _superGroup(pDayString, pPeriod)
|
|
60
|
+
{
|
|
61
|
+
let tmpYear = String(pDayString || '').slice(0, 4);
|
|
62
|
+
if (pPeriod === 'Day' || pPeriod === 'Week' || pPeriod === 'Month') { return tmpYear + '-Q' + _quarterOf(pDayString); }
|
|
63
|
+
if (pPeriod === 'Quarter') { return tmpYear; }
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _colorFor(pStackBy, pKey, pIndex)
|
|
68
|
+
{
|
|
69
|
+
if (pStackBy === 'ActorKind') { return _ACTOR_COLORS[pKey] || _FALLBACK_SEQUENCE[pIndex % _FALLBACK_SEQUENCE.length]; }
|
|
70
|
+
return _TYPE_COLORS[pKey] || _FALLBACK_SEQUENCE[pIndex % _FALLBACK_SEQUENCE.length];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* pModel:
|
|
75
|
+
* { Period, StackBy ('ActivityType'|'ActorKind'), Measure ('EventCount'|'Volume'),
|
|
76
|
+
* Columns: [ { Key, Title, Accent } ], Buckets: [ { PeriodStart, PeriodKey, Domain, ActivityType, ActorKind, EventCount, Volume } ] }
|
|
77
|
+
*/
|
|
78
|
+
function buildSVG(pModel)
|
|
79
|
+
{
|
|
80
|
+
let tmpModel = pModel || {};
|
|
81
|
+
let tmpPeriod = tmpModel.Period || 'Week';
|
|
82
|
+
let tmpStackBy = (tmpModel.StackBy === 'ActorKind') ? 'ActorKind' : 'ActivityType';
|
|
83
|
+
let tmpMeasure = (tmpModel.Measure === 'Volume') ? 'Volume' : 'EventCount';
|
|
84
|
+
let tmpColumns = Array.isArray(tmpModel.Columns) && tmpModel.Columns.length ? tmpModel.Columns : [{ Key: 'WorkItems', Title: 'Work Items', Accent: '#e0891e' }, { Key: 'Knowledge', Title: 'Knowledge', Accent: '#3f7fd0' }];
|
|
85
|
+
let tmpBuckets = Array.isArray(tmpModel.Buckets) ? tmpModel.Buckets : [];
|
|
86
|
+
|
|
87
|
+
// --- geometry ---
|
|
88
|
+
const WIDTH = 940;
|
|
89
|
+
const PAD_L = 104; // period-label gutter
|
|
90
|
+
const PAD_R = 16;
|
|
91
|
+
const TOP = 46; // title + column headers
|
|
92
|
+
const LEGEND_H = 30;
|
|
93
|
+
const ROW_H = 26;
|
|
94
|
+
const BAR_H = 15;
|
|
95
|
+
const COL_GAP = 26;
|
|
96
|
+
const COUNT_W = 40; // room for the total label after a bar
|
|
97
|
+
|
|
98
|
+
// --- distinct, ordered periods (union across columns), sorted by PeriodStart ---
|
|
99
|
+
let tmpPeriodOrder = [];
|
|
100
|
+
let tmpPeriodSeen = {};
|
|
101
|
+
tmpBuckets.slice().sort((pA, pB) => String(pA.PeriodStart).localeCompare(String(pB.PeriodStart))).forEach((pB) =>
|
|
102
|
+
{
|
|
103
|
+
if (!tmpPeriodSeen[pB.PeriodKey]) { tmpPeriodSeen[pB.PeriodKey] = { PeriodKey: pB.PeriodKey, PeriodStart: pB.PeriodStart }; tmpPeriodOrder.push(tmpPeriodSeen[pB.PeriodKey]); }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- pivot: period -> column -> stackKey -> measure; plus totals + distinct stack keys ---
|
|
107
|
+
let tmpPivot = {};
|
|
108
|
+
let tmpStackKeys = [];
|
|
109
|
+
let tmpStackSeen = {};
|
|
110
|
+
let tmpMaxTotal = 0;
|
|
111
|
+
tmpBuckets.forEach((pB) =>
|
|
112
|
+
{
|
|
113
|
+
let tmpStackKey = String((tmpStackBy === 'ActorKind') ? (pB.ActorKind || 'System') : (pB.ActivityType || 'Other'));
|
|
114
|
+
if (!tmpStackSeen[tmpStackKey]) { tmpStackSeen[tmpStackKey] = true; tmpStackKeys.push(tmpStackKey); }
|
|
115
|
+
let tmpCell = tmpPivot[pB.PeriodKey] || (tmpPivot[pB.PeriodKey] = {});
|
|
116
|
+
let tmpColCell = tmpCell[pB.Domain] || (tmpCell[pB.Domain] = { Total: 0, Segments: {} });
|
|
117
|
+
let tmpValue = Number(pB[tmpMeasure]) || 0;
|
|
118
|
+
tmpColCell.Segments[tmpStackKey] = (tmpColCell.Segments[tmpStackKey] || 0) + tmpValue;
|
|
119
|
+
tmpColCell.Total += tmpValue;
|
|
120
|
+
if (tmpColCell.Total > tmpMaxTotal) { tmpMaxTotal = tmpColCell.Total; }
|
|
121
|
+
});
|
|
122
|
+
tmpStackKeys.sort();
|
|
123
|
+
if (tmpMaxTotal <= 0) { tmpMaxTotal = 1; }
|
|
124
|
+
|
|
125
|
+
let tmpColorByKey = {};
|
|
126
|
+
tmpStackKeys.forEach((pKey, pIndex) => { tmpColorByKey[pKey] = _colorFor(tmpStackBy, pKey, pIndex); });
|
|
127
|
+
|
|
128
|
+
// --- column x-geometry ---
|
|
129
|
+
let tmpChartWidth = WIDTH - PAD_L - PAD_R;
|
|
130
|
+
let tmpColWidth = (tmpChartWidth - COL_GAP * (tmpColumns.length - 1)) / tmpColumns.length;
|
|
131
|
+
let tmpBarMax = tmpColWidth - COUNT_W;
|
|
132
|
+
let tmpColX = tmpColumns.map((pCol, pIndex) => PAD_L + pIndex * (tmpColWidth + COL_GAP));
|
|
133
|
+
|
|
134
|
+
// --- empty state ---
|
|
135
|
+
if (!tmpPeriodOrder.length)
|
|
136
|
+
{
|
|
137
|
+
let tmpH = TOP + 80;
|
|
138
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + WIDTH + ' ' + tmpH + '" width="100%" role="img" aria-label="Activity chart, no data">'
|
|
139
|
+
+ '<text x="' + (WIDTH / 2) + '" y="' + (tmpH / 2) + '" text-anchor="middle" font-family="sans-serif" font-size="14" fill="' + _C.muted + '">No activity in this range yet.</text></svg>';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let tmpChartTop = TOP + LEGEND_H;
|
|
143
|
+
let tmpHeight = tmpChartTop + tmpPeriodOrder.length * ROW_H + 20;
|
|
144
|
+
let tmpSVG = [];
|
|
145
|
+
tmpSVG.push('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + WIDTH + ' ' + tmpHeight + '" width="100%" role="img" aria-label="System activity, one bar per ' + _esc(tmpPeriod) + ' stacked by ' + _esc(tmpStackBy) + '" font-family="-apple-system, Segoe UI, Roboto, sans-serif">');
|
|
146
|
+
|
|
147
|
+
// legend
|
|
148
|
+
let tmpLegendX = PAD_L;
|
|
149
|
+
tmpSVG.push('<text x="0" y="' + (TOP - 22) + '" font-size="15" font-weight="600" fill="' + _C.text + '">System Activity</text>');
|
|
150
|
+
tmpStackKeys.forEach((pKey) =>
|
|
151
|
+
{
|
|
152
|
+
tmpSVG.push('<rect x="' + tmpLegendX + '" y="' + (TOP - 4) + '" width="11" height="11" rx="2" fill="' + tmpColorByKey[pKey] + '"/>');
|
|
153
|
+
tmpSVG.push('<text x="' + (tmpLegendX + 16) + '" y="' + (TOP + 5) + '" font-size="12" fill="' + _C.muted + '">' + _esc(pKey) + '</text>');
|
|
154
|
+
tmpLegendX += 20 + _esc(pKey).length * 7.2 + 14;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// column headers (accent-colored text + underline, never text on a fill)
|
|
158
|
+
tmpColumns.forEach((pCol, pIndex) =>
|
|
159
|
+
{
|
|
160
|
+
let tmpX = tmpColX[pIndex];
|
|
161
|
+
tmpSVG.push('<text x="' + tmpX + '" y="' + (tmpChartTop - 8) + '" font-size="13" font-weight="600" fill="' + (pCol.Accent || _C.text) + '">' + _esc(pCol.Title || pCol.Key) + '</text>');
|
|
162
|
+
tmpSVG.push('<rect x="' + tmpX + '" y="' + (tmpChartTop - 4) + '" width="' + Math.max(40, tmpColWidth * 0.4) + '" height="2" fill="' + (pCol.Accent || _C.border) + '"/>');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// rows
|
|
166
|
+
let tmpPrevGroup = null;
|
|
167
|
+
tmpPeriodOrder.forEach((pPeriodRow, pRowIndex) =>
|
|
168
|
+
{
|
|
169
|
+
let tmpY = tmpChartTop + pRowIndex * ROW_H;
|
|
170
|
+
let tmpGroup = _superGroup(pPeriodRow.PeriodStart, tmpPeriod);
|
|
171
|
+
if (tmpGroup && tmpGroup !== tmpPrevGroup)
|
|
172
|
+
{
|
|
173
|
+
// super-group separator across the full width + label in the gutter
|
|
174
|
+
tmpSVG.push('<line x1="0" y1="' + (tmpY - 2) + '" x2="' + (WIDTH - PAD_R) + '" y2="' + (tmpY - 2) + '" stroke="' + _C.border + '" stroke-width="1"/>');
|
|
175
|
+
tmpSVG.push('<text x="0" y="' + (tmpY + 11) + '" font-size="10" font-weight="600" fill="' + _C.muted + '">' + _esc(tmpGroup) + '</text>');
|
|
176
|
+
tmpPrevGroup = tmpGroup;
|
|
177
|
+
}
|
|
178
|
+
// period label
|
|
179
|
+
tmpSVG.push('<text x="' + (PAD_L - 12) + '" y="' + (tmpY + BAR_H) + '" text-anchor="end" font-size="11" fill="' + _C.muted + '">' + _esc(pPeriodRow.PeriodKey) + '</text>');
|
|
180
|
+
|
|
181
|
+
tmpColumns.forEach((pCol, pColIndex) =>
|
|
182
|
+
{
|
|
183
|
+
let tmpX0 = tmpColX[pColIndex];
|
|
184
|
+
// track
|
|
185
|
+
tmpSVG.push('<rect x="' + tmpX0 + '" y="' + (tmpY + 3) + '" width="' + tmpBarMax + '" height="' + BAR_H + '" rx="3" fill="' + _C.track + '"/>');
|
|
186
|
+
let tmpCell = (tmpPivot[pPeriodRow.PeriodKey] || {})[pCol.Key];
|
|
187
|
+
if (tmpCell && tmpCell.Total > 0)
|
|
188
|
+
{
|
|
189
|
+
let tmpX = tmpX0;
|
|
190
|
+
tmpStackKeys.forEach((pKey) =>
|
|
191
|
+
{
|
|
192
|
+
let tmpValue = tmpCell.Segments[pKey] || 0;
|
|
193
|
+
if (tmpValue <= 0) { return; }
|
|
194
|
+
let tmpW = (tmpValue / tmpMaxTotal) * tmpBarMax;
|
|
195
|
+
tmpSVG.push('<rect x="' + tmpX.toFixed(1) + '" y="' + (tmpY + 3) + '" width="' + tmpW.toFixed(1) + '" height="' + BAR_H + '" fill="' + tmpColorByKey[pKey] + '"><title>' + _esc(pCol.Title + ' ' + pKey + ': ' + tmpValue) + '</title></rect>');
|
|
196
|
+
tmpX += tmpW;
|
|
197
|
+
});
|
|
198
|
+
tmpSVG.push('<text x="' + (tmpX + 6).toFixed(1) + '" y="' + (tmpY + BAR_H) + '" font-size="11" font-weight="600" fill="' + _C.text + '">' + tmpCell.Total + '</text>');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
tmpSVG.push('</svg>');
|
|
204
|
+
return tmpSVG.join('');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { buildSVG: buildSVG, _superGroup: _superGroup, _TYPE_COLORS: _TYPE_COLORS, _ACTOR_COLORS: _ACTOR_COLORS };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pict-section-activitygraph entry.
|
|
5
|
+
*
|
|
6
|
+
* The default export is the view class (with default_configuration hung off it, as every pict-section
|
|
7
|
+
* does), so a host registers it with:
|
|
8
|
+
* pict.addView('MyActivityGraph', libActivityGraph.default_configuration, libActivityGraph);
|
|
9
|
+
* Override ActivityGraph.Columns / Period / PeriodChangeHandler and the Renderables[].ContentDestination
|
|
10
|
+
* Address in the config to place and wire an instance. The pure SVG renderer is exported as .Renderer for
|
|
11
|
+
* tests and headless rasterization.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const libActivityGraphView = require('./views/PictView-ActivityGraph.js');
|
|
15
|
+
|
|
16
|
+
module.exports = libActivityGraphView;
|
|
17
|
+
module.exports.default_configuration = libActivityGraphView.default_configuration;
|
|
18
|
+
module.exports.PictViewActivityGraph = libActivityGraphView;
|
|
19
|
+
module.exports.Renderer = require('./Pict-ActivityGraph-Renderer.js');
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const libPictView = require('pict-view');
|
|
4
|
+
const libRenderer = require('../Pict-ActivityGraph-Renderer.js');
|
|
5
|
+
const libCSS = require('../Pict-ActivityGraph-CSS.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PictView-ActivityGraph
|
|
9
|
+
* ----------------------
|
|
10
|
+
* Renders a stacked-bar activity timeline (see Pict-ActivityGraph-Renderer for the SVG). The host feeds
|
|
11
|
+
* it buckets with setData(); the built-in controls switch the granularity (Day..Year), the stack
|
|
12
|
+
* dimension (by type / by actor), and the measure (count / volume). Granularity changes call the
|
|
13
|
+
* host-supplied PeriodChangeHandler so the host can refetch that period's rows; the stack + measure
|
|
14
|
+
* toggles just re-render the buckets already in hand (each bucket carries both dimensions).
|
|
15
|
+
*
|
|
16
|
+
* The chart SVG and the controls are computed content, so they are injected with ContentAssignment
|
|
17
|
+
* rather than expressed as data-bound templates -- the same shape the moodboard / histogram sections use
|
|
18
|
+
* for their bespoke visuals. Inline handlers reference this specific instance by its registered Hash, so
|
|
19
|
+
* several instances on one page stay independent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const _DEFAULTS =
|
|
23
|
+
{
|
|
24
|
+
Period: 'Week',
|
|
25
|
+
Periods: ['Day', 'Week', 'Month', 'Quarter', 'Year'],
|
|
26
|
+
StackBy: 'ActivityType',
|
|
27
|
+
Measure: 'EventCount',
|
|
28
|
+
Columns: [
|
|
29
|
+
{ Key: 'WorkItems', Title: 'Work Items', Accent: '#e0891e' },
|
|
30
|
+
{ Key: 'Knowledge', Title: 'Knowledge', Accent: '#3f7fd0' }
|
|
31
|
+
]
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const _ViewConfiguration =
|
|
35
|
+
{
|
|
36
|
+
ViewIdentifier: 'Pict-ActivityGraph',
|
|
37
|
+
DefaultRenderable: 'Pict-ActivityGraph-Container',
|
|
38
|
+
DefaultDestinationAddress: '#Pict-ActivityGraph-Container',
|
|
39
|
+
AutoRender: false,
|
|
40
|
+
CSS: libCSS,
|
|
41
|
+
CSSHash: 'Pict-Section-ActivityGraph-CSS',
|
|
42
|
+
ActivityGraph: _DEFAULTS,
|
|
43
|
+
Templates: [
|
|
44
|
+
{
|
|
45
|
+
Hash: 'Pict-ActivityGraph-Container',
|
|
46
|
+
Template: /*html*/`<div class="pict-activitygraph">
|
|
47
|
+
<div class="pag-controls" id="Pict-ActivityGraph-Controls"></div>
|
|
48
|
+
<div class="pag-chart" id="Pict-ActivityGraph-Chart"></div>
|
|
49
|
+
</div>`
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
Renderables: [
|
|
53
|
+
{
|
|
54
|
+
RenderableHash: 'Pict-ActivityGraph-Container',
|
|
55
|
+
TemplateHash: 'Pict-ActivityGraph-Container',
|
|
56
|
+
DestinationAddress: '#Pict-ActivityGraph-Container',
|
|
57
|
+
RenderMethod: 'replace'
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
class PictViewActivityGraph extends libPictView
|
|
63
|
+
{
|
|
64
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
65
|
+
{
|
|
66
|
+
super(pFable, pOptions, pServiceHash);
|
|
67
|
+
let tmpConfig = (this.options && this.options.ActivityGraph) || {};
|
|
68
|
+
this._State =
|
|
69
|
+
{
|
|
70
|
+
Period: tmpConfig.Period || _DEFAULTS.Period,
|
|
71
|
+
Periods: Array.isArray(tmpConfig.Periods) ? tmpConfig.Periods : _DEFAULTS.Periods,
|
|
72
|
+
StackBy: tmpConfig.StackBy || _DEFAULTS.StackBy,
|
|
73
|
+
Measure: tmpConfig.Measure || _DEFAULTS.Measure,
|
|
74
|
+
Columns: Array.isArray(tmpConfig.Columns) ? tmpConfig.Columns : _DEFAULTS.Columns,
|
|
75
|
+
Buckets: []
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The hash a host reaches this instance by (addView name), for inline handlers. Falls back to the
|
|
80
|
+
// view identifier for a single-instance registration.
|
|
81
|
+
get _InstanceHash() { return this.Hash || _ViewConfiguration.ViewIdentifier; }
|
|
82
|
+
|
|
83
|
+
// Host API: hand the view a period's buckets. Optionally also set the period (when the host refetched
|
|
84
|
+
// for a new granularity).
|
|
85
|
+
setData(pPayload)
|
|
86
|
+
{
|
|
87
|
+
let tmpPayload = pPayload || {};
|
|
88
|
+
if (tmpPayload.Period) { this._State.Period = tmpPayload.Period; }
|
|
89
|
+
if (Array.isArray(tmpPayload.Columns)) { this._State.Columns = tmpPayload.Columns; }
|
|
90
|
+
this._State.Buckets = Array.isArray(tmpPayload.Buckets) ? tmpPayload.Buckets : (Array.isArray(tmpPayload.Rows) ? tmpPayload.Rows : []);
|
|
91
|
+
this.refresh();
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setPeriod(pPeriod)
|
|
96
|
+
{
|
|
97
|
+
if (this._State.Periods.indexOf(pPeriod) < 0) { return; }
|
|
98
|
+
this._State.Period = pPeriod;
|
|
99
|
+
let tmpHandler = (this.options && this.options.ActivityGraph && this.options.ActivityGraph.PeriodChangeHandler);
|
|
100
|
+
if (typeof tmpHandler === 'function')
|
|
101
|
+
{
|
|
102
|
+
// Host refetches the new period's rows and calls setData(); reflect the active button now.
|
|
103
|
+
this._injectControls();
|
|
104
|
+
tmpHandler(pPeriod, this);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.refresh();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setStackBy(pStackBy)
|
|
111
|
+
{
|
|
112
|
+
this._State.StackBy = (pStackBy === 'ActorKind') ? 'ActorKind' : 'ActivityType';
|
|
113
|
+
this.refresh();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setMeasure(pMeasure)
|
|
117
|
+
{
|
|
118
|
+
this._State.Measure = (pMeasure === 'Volume') ? 'Volume' : 'EventCount';
|
|
119
|
+
this.refresh();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_button(pLabel, pActive, pMethod, pArg)
|
|
123
|
+
{
|
|
124
|
+
return '<button class="pag-btn' + (pActive ? ' pag-btn-on' : '') + '" onclick="_Pict.views[\'' + this._InstanceHash + '\'].' + pMethod + '(\'' + pArg + '\')">' + pLabel + '</button>';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_injectControls()
|
|
128
|
+
{
|
|
129
|
+
let tmpHTML = '<div class="pag-group">';
|
|
130
|
+
this._State.Periods.forEach((pPeriod) => { tmpHTML += this._button(pPeriod, this._State.Period === pPeriod, 'setPeriod', pPeriod); });
|
|
131
|
+
tmpHTML += '</div><div class="pag-group pag-group-right">'
|
|
132
|
+
+ '<span class="pag-label">Stack</span>'
|
|
133
|
+
+ this._button('Type', this._State.StackBy === 'ActivityType', 'setStackBy', 'ActivityType')
|
|
134
|
+
+ this._button('Actor', this._State.StackBy === 'ActorKind', 'setStackBy', 'ActorKind')
|
|
135
|
+
+ '<span class="pag-label">Measure</span>'
|
|
136
|
+
+ this._button('Count', this._State.Measure === 'EventCount', 'setMeasure', 'EventCount')
|
|
137
|
+
+ this._button('Volume', this._State.Measure === 'Volume', 'setMeasure', 'Volume')
|
|
138
|
+
+ '</div>';
|
|
139
|
+
this.pict.ContentAssignment.assignContent('#Pict-ActivityGraph-Controls', tmpHTML);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_injectChart()
|
|
143
|
+
{
|
|
144
|
+
let tmpSVG = libRenderer.buildSVG(
|
|
145
|
+
{
|
|
146
|
+
Period: this._State.Period,
|
|
147
|
+
StackBy: this._State.StackBy,
|
|
148
|
+
Measure: this._State.Measure,
|
|
149
|
+
Columns: this._State.Columns,
|
|
150
|
+
Buckets: this._State.Buckets
|
|
151
|
+
});
|
|
152
|
+
this.pict.ContentAssignment.assignContent('#Pict-ActivityGraph-Chart', tmpSVG);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
refresh()
|
|
156
|
+
{
|
|
157
|
+
this._injectControls();
|
|
158
|
+
this._injectChart();
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
onAfterRender(pRenderable, pAddress, pRecord, pContent)
|
|
163
|
+
{
|
|
164
|
+
this.refresh();
|
|
165
|
+
this.pict.CSSMap.injectCSS();
|
|
166
|
+
return super.onAfterRender(pRenderable, pAddress, pRecord, pContent);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = PictViewActivityGraph;
|
|
171
|
+
module.exports.default_configuration = _ViewConfiguration;
|