forked from nowthis/sankeymatic
-
Notifications
You must be signed in to change notification settings - Fork 0
/
main.js
1761 lines (1596 loc) · 63.5 KB
/
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'use strict';
import {Canvg} from 'canvg'
import * as d3 from 'd3'
// side effect
import d3Sankey from './build/sankey.js'
import {
IN, OUT, BEFORE, AFTER,
fontMetrics,
highlightStyles,
settingsMarker,
settingsAppliedPrefix,
skmSettings,
colorGray60,
reWholeNumber,
reDecimal,
reYesNo,
reYes,
reCommentLine,
reSettingsValue,
reSettingsText,
reNodeLine,
reFlowLine,
reMoveLine,
movesMarker,
reFlowTargetWithSuffix,
reColorPlusOpacity,
reBareColor,
reRGBColor
} from "./build/constants.js"
class InvalidSyntax extends Error {
constructor(message) {
super(message);
}
}
// Isolated logic for managing messages to the user:
const msg = {
areas: new Map([
['issue', {id: 'issue_messages', class: 'errormessage'}],
['difference', {id: 'imbalance_messages', class: 'differencemessage'}],
['total', {id: 'totals_area', class: ''}],
['info', {id: 'info_messages', class: 'okmessage'}],
]),
add: (msgHTML, msgType = 'info') => {
const msgData = msg.areas.get(msgType) || msg.areas.get('info');
console.log(msgData, msgHTML, msgType)
}
};
// NODE-handling functions:
// getTrueNodeName: Parse cases where the node name is in strike-through
// format (e.g. '-hidden label-') and return:
// - trueName: the real node name (without minuses, if present)
// - hideLabel: true if the name was struck through
function getTrueNodeName(uniqueNodes, rawName) {
const hiddenNameMatches = rawName.match(/^-(.*)-$/),
hideThisLabel = hiddenNameMatches !== null,
trueName = hideThisLabel ? hiddenNameMatches[1] : rawName;
return {trueName: trueName, hideLabel: hideThisLabel};
}
// addNodeName: Make sure a node's name is present in the 'unique' list
// with the lowest row number the node has appeared on:
function addNodeName(uniqueNodes, nodeName, row) {
const nameInfo = getTrueNodeName(uniqueNodes, nodeName);
// Have we seen this node before?
if (uniqueNodes.has(nameInfo.trueName)) {
const thisNode = uniqueNodes.get(nameInfo.trueName);
// If so, should the new row # replace the stored row #?:
if (thisNode.sourceRow > row) {
thisNode.sourceRow = row;
}
// If ANY instance of the name was struck through, then set hideLabel:
thisNode.hideLabel ||= nameInfo.hideLabel;
} else {
// Set up the node's raw object, keyed to the name:
uniqueNodes.set(nameInfo.trueName, {
name: nameInfo.trueName,
hideLabel: nameInfo.hideLabel,
sourceRow: row,
paintInputs: [],
});
}
}
// getUniqueNode: return node 'foo' even when referenced as '-foo-':
function getUniqueNode(uniqueNodes, nodeName) {
return uniqueNodes.get(getTrueNodeName(uniqueNodes, nodeName).trueName);
}
// updateNodeAttrs: Update an existing node's attributes.
// Note: If there are multiple lines specifying a value for the same
// parameter for a node, the LAST declaration will win.
function updateNodeAttrs(uniqueNodes, nodeParams) {
// Just in case this is the first appearance of the name (or we've
// encountered an earlier row than the node declaration), add it to
// the big list:
addNodeName(uniqueNodes, nodeParams.name, nodeParams.sourceRow);
// We've already used the 'sourceRow' value and don't want it to
// overwrite anything, so take it out of the params object:
delete nodeParams.sourceRow;
// If there's a color and it's a color CODE, put back the #:
// TODO: honor or translate color names?
if (reBareColor.test(nodeParams.color)) {
nodeParams.color = `#${nodeParams.color}`;
}
// Allow for special name syntaxes (like strike-through):
const targetNode = getUniqueNode(uniqueNodes, nodeParams.name);
// Don't overwrite the 'name' value here, it can mess up tooltips:
delete nodeParams.name;
Object.entries(nodeParams).forEach(([pName, pVal]) => {
if (typeof pVal !== 'undefined' && pVal !== null && pVal !== '') {
targetNode[pName] = pVal;
}
});
}
/**
*
* @param input
* @param options A list of options
* @param options.rememveredMoves Used to track the user's repositioning of specific nodes. Format is: nodeName => [moveX, moveY]
* @returns {null}
*/
export default function sankey(input, options) {
const defaultOptions = {
targetElement: null,
targetElementId: 'chart',
rememberedMoves: new Map(),
layoutReverse: false
}
const opts = {
...defaultOptions,
...options,
}
const rememberedMoves = opts.rememberedMoves
const targetElement = opts.targetElement || document.getElementById(opts.targetElementId)
let [maxDecimalPlaces, maxNodeIndex, maxNodeVal] = [0, 0, 0];
const uniqueNodes = new Map();
// Go through lots of validation with plenty of bailout points and
// informative messages for the poor soul trying to do this.
// Note: Checking the 'Transparent' background-color box *no longer* means
// that the background-color-picker is pointless; it still affects the color
// value which will be given to "Made with SankeyMATIC".
// Therefore, we no longer disable the Background Color element, even when
// 'Transparent' is checked.
// Time to parse the user's input.
// Before we do anything at all, split it into an array of lines with
// no whitespace at either end.
// As part of this step, we make sure to drop any zero-width spaces
// which may have been appended or prepended to lines (e.g. when pasted
// from PowerPoint), then trim again.
const origSourceLines = input.split('\n'),
sourceLines = origSourceLines.map(
(l) => l.trim()
.replace(/^\u200B+/, '')
.replace(/\u200B+$/, '')
.trim()
),
invalidLines = [], // contains objects with a 'value' and 'message'
linesWithSettings = new Set(),
linesWithValidSettings = new Set();
function warnAbout(line, warnMsg) {
invalidLines.push({value: line, message: warnMsg});
}
// Search for Settings we can apply:
const settings = {}
let currentSettingGroup = '';
sourceLines.forEach((lineIn, row) => {
// Is it a Move line?
const moveParts = lineIn.match(reMoveLine);
if (moveParts !== null) {
linesWithSettings.add(row);
// Save this as a rememberedMove.
// We don't verify the name because we don't yet know the list to
// match against. Assume the node names are provided in good faith.
const [nodeName, moveX, moveY] = moveParts.slice(-3);
rememberedMoves.set(nodeName, [Number(moveX), Number(moveY)]);
linesWithValidSettings.add(row);
return;
}
// Does it look like a regular Settings line (number, keyword, color)
// OR a Settings line with a quoted string?
const settingParts = lineIn.match(reSettingsValue) ?? lineIn.match(reSettingsText);
// If either was found, let's process it:
if (settingParts !== null) {
// We found something, so remember this row index:
linesWithSettings.add(row);
// Derive the setting name we're looking at:
let origSettingName = settingParts[1],
settingName = origSettingName.replace(/\s+/g, '_');
// Syntactic sugar - if the user typed the long version of a word,
// fix it up so it's just the 1st letter so it will work:
'width height left right top bottom' // => w, h, l, r, t, b
.split(' ')
.filter((l) => settingName.endsWith(l))
.forEach((long) => {
settingName = settingName.replace(long, long[0]);
});
// If the given settingName still isn't valid, and it isn't already
// two words, try it with the prefix from the prior settings row:
if (!skmSettings.has(settingName)
&& !/_/.test(settingName)
&& currentSettingGroup.length) {
settingName = `${currentSettingGroup}_${settingName}`;
origSettingName = `${currentSettingGroup} ${origSettingName}`;
}
// Update the group-prefix, whether or not the value validates
// below. (Better to honor this prefix than to use one from
// further up.):
currentSettingGroup = settingName.split('_')[0];
const settingData = skmSettings.get(settingName);
// Validate & apply:
if (settingData) {
const settingValue = settingParts[2]
const sizeObj = {}
const [validValue, finalValue] = settingIsValid(settingData, settingValue, sizeObj)
if (validValue) {
settings[settingName] = finalValue
linesWithValidSettings.add(row);
return;
}
// The setting exists but the value wasn't right:
warnAbout(
settingValue,
`Invalid value for <strong>${origSettingName}<strong>`
);
} else {
// There wasn't a setting matching this name:
warnAbout(origSettingName, 'Not a valid setting name');
}
}
});
// Parse inputs into: approvedNodes, approvedFlows
const goodFlows = [],
approvedNodes = [],
approvedFlows = [];
// Loop through all the non-setting input lines:
sourceLines.filter((l, i) => !linesWithSettings.has(i))
.forEach((lineIn, row) => {
// Is it a blank line OR a comment? Skip it entirely:
if (lineIn === '' || reCommentLine.test(lineIn)) {
return;
}
// Does this line look like a Node?
let matches = lineIn.match(reNodeLine);
if (matches !== null) {
// Save/update it in the uniqueNodes structure:
updateNodeAttrs(uniqueNodes, {
name: matches[1].trim(),
color: matches[2],
opacity: matches[3],
paintInputs: [matches[4], matches[5]],
sourceRow: row,
});
// No need to process this as a Data line, let's move on:
return;
}
// Does this line look like a Flow?
matches = lineIn.match(reFlowLine);
if (matches !== null) {
// The Amount looked trivially like a number; reject the line
// if it really isn't:
const amountIn = matches[2].replace(/\s/g, '');
if (!isNumeric(amountIn)) {
warnAbout(lineIn, 'The Amount is not a valid decimal number');
return;
}
// Diagrams don't currently support negative numbers or 0:
if (amountIn <= 0) {
warnAbout(lineIn, 'Amounts must be greater than 0');
return;
}
// All seems well, save it as good:
goodFlows.push({
source: matches[1].trim(),
target: matches[3].trim(),
amount: amountIn,
sourceRow: row,
});
// We need to know the maximum precision of the inputs (greatest
// # of characters to the RIGHT of the decimal) for some error
// checking operations (& display) later:
maxDecimalPlaces = Math.max(
maxDecimalPlaces,
(amountIn.split('.')[1] || '').length
);
return;
}
// This is a non-blank line which did not match any pattern:
warnAbout(
lineIn,
'Does not match the format of a Flow or Node or Setting'
);
});
// TODO: Disable useless precision checkbox if maxDecimalPlaces === 0
// TODO: Look for cycles and post errors about them
// Mention any un-parseable lines:
invalidLines.forEach((parsingError) => {
msg.add(
`${parsingError.message}: ${highlightSafeValue(parsingError.value)}`,
'issue'
);
});
// Make the final list of Flows:
const graphIsReversed = opts.layoutReverse;
goodFlows.forEach((flow) => {
// Look for extra content about this flow on the target-node end of the
// string:
let [flowColor, opacity] = ['', ''];
// Try to parse; there may be extra info that isn't actually the name:
// Format of the Target node can be: Target node [#color[.opacity]]
// e.g. 'x [...] y #99aa00' or 'x [...] y #99aa00.25'
// Look for a candidate string starting with # for color info:
const flowTargetPlus = flow.target.match(reFlowTargetWithSuffix);
if (flowTargetPlus !== null) {
// IFF the # string matches a stricter pattern, separate the target
// string into parts:
const [, possibleNodeName, possibleColor] = flowTargetPlus,
colorOpacity = possibleColor.match(reColorPlusOpacity);
if (colorOpacity !== null) {
// Looks like we found a color or opacity or both.
// Update the target's name with the trimmed string:
flow.target = possibleNodeName;
// If there was a color, adopt it:
if (colorOpacity[1]) {
flowColor = `#${colorOpacity[1]}`;
}
// If there was an opacity, adopt it:
if (colorOpacity[2]) {
opacity = colorOpacity[2];
}
}
// Otherwise just treat it as part of the nodename, e.g. "Team #1"
}
// Make sure the node names get saved; it may be their only appearance:
addNodeName(uniqueNodes, flow.source, flow.sourceRow);
addNodeName(uniqueNodes, flow.target, flow.sourceRow + 0.5);
// Add the updated flow to the list of approved flows:
const f = {
index: approvedFlows.length,
source: getUniqueNode(uniqueNodes, flow.source),
target: getUniqueNode(uniqueNodes, flow.target),
value: flow.amount,
color: flowColor,
opacity: opacity,
hovering: false,
sourceRow: flow.sourceRow,
};
if (graphIsReversed) {
[f.source, f.target] = [f.target, f.source];
}
approvedFlows.push(f);
});
// Construct the final list of approved_nodes, sorted by their order of
// appearance in the source:
Array.from(uniqueNodes.values())
.sort((a, b) => a.sourceRow - b.sourceRow)
.forEach((n) => {
// Set up color inheritance signals from '<<' and '>>' indicators:
const paintL = n.paintInputs.some((s) => s === '<<'),
paintR = n.paintInputs.some((s) => s === '>>');
// If the graph is reversed, swap the directions:
n.paint = {
[BEFORE]: graphIsReversed ? paintR : paintL,
[AFTER]: graphIsReversed ? paintL : paintR,
};
// After establishing the above, the raw paint inputs aren't needed:
delete n.paintInputs;
n.index = approvedNodes.length;
approvedNodes.push(n);
});
// MARK Import settings from the page's UI:
const approvedCfg = {
size_w: 600,
size_h: 600,
};
skmSettings.forEach((fldData, fldName) => {
const [_, defaultVal] = fldData
approvedCfg[fldName] = defaultVal;
})
Object.entries(settings).forEach(([key, value]) => {
approvedCfg[key] = value;
})
// Since we know the canvas' intended size now, go ahead & set that up
// (before we potentially quit):
targetElement.style.height = `${approvedCfg.size_h}px`;
targetElement.style.width = `${approvedCfg.size_w}px`;
// Also update the PNG download buttons' title text with these dimensions:
/*
[1, 2, 4, 6].forEach((s) => {
el(`save_as_png_${s}x`).title
= `PNG image file: ${approvedCfg.size_w * s} x ${approvedCfg.size_h * s}`;
});*/
// Mark as 'applied' any setting line which was successful.
// (This will put the interactive UI component in charge.)
// Un-commenting a settings line will apply it again (and then immediately
// comment it again).
// Use origSourceLines so that any original indentation is preserved:
const updatedSourceLines = origSourceLines
.map((l, i) => (
linesWithValidSettings.has(i) ? `${settingsAppliedPrefix}${l}` : l
));
// Were there any good flows at all? If not, offer a little help and then
// EXIT EARLY:
if (!goodFlows.length) {
throw new InvalidSyntax('No good flow found')
}
// MARK Diagram does have data, so prepare to render.
// Set up the numberStyle object:
const [groupMark, decimalMark] = approvedCfg.value_format,
numberStyle = {
marks: {
group: groupMark === 'X' ? '' : groupMark,
decimal: decimalMark,
},
decimalPlaces: maxDecimalPlaces,
// 'trimString' = string to be used in the d3.format expression later:
trimString: approvedCfg.labelvalue_fullprecision ? '' : '~',
prefix: approvedCfg.value_prefix,
suffix: approvedCfg.value_suffix,
};
// Deal with inheritance swap if graph is reversed:
if (opts.layoutReverse) {
// Only two of the possible values require any change:
switch (approvedCfg.flow_inheritfrom) {
case 'source':
approvedCfg.flow_inheritfrom = 'target';
break;
case 'target':
approvedCfg.flow_inheritfrom = 'source';
break;
// no default
}
}
// All is ready. Do the actual rendering:
render_sankey(approvedNodes, approvedFlows, approvedCfg, numberStyle);
// MARK Post-Render Activity - various stats & message updates.
// withUnits: Format a value with the current style.
function withUnits(n) {
return formatUserData(n, numberStyle);
}
// explainSum: Returns an html string showing the flow amounts which
// add up to a node's total value in or out.
function explainSum(n, dir) {
const formattedSum = withUnits(n.total[dir]),
flowGroup = n.flows[dir].filter((f) => !f.isAShadow),
flowCt = flowGroup.length;
if (flowCt === 1) {
return formattedSum;
}
// When there are multiple amounts, the amount appears as a hover
// target with a tooltip showing the breakdown in descending order.
const breakdown = flowGroup.map((f) => f.value)
.sort((a, b) => b - a)
.map((v) => withUnits(v))
.join(' + ');
return `<dfn title="${formattedSum} from ${flowCt} `
+ `Flows: ${breakdown}">${formattedSum}</dfn>`;
}
// Given maxDecimalPlaces, we can derive the smallest important
// difference, defined as smallest-input-decimal/10; this lets us work
// around various binary/decimal math issues.
const epsilonDifference = 10 ** (-maxDecimalPlaces - 1),
differences = [],
grandTotal = {[IN]: 0, [OUT]: 0};
// Look for imbalances in Nodes so we can respond to them:
approvedNodes.forEach((n, i) => {
// Note: After rendering, there are now more keys in the node records,
// including 'total' and 'value'.
// Skip checking any nodes with 0 as the From or To amount; those are
// the origins & endpoints for the whole graph and don't qualify:
if (n.total[IN] > 0 && n.total[OUT] > 0) {
const difference = n.total[IN] - n.total[OUT];
// Is there a difference big enough to matter? (i.e. > epsilon)
// We'll always calculate this, even if not shown to the user.
if (Math.abs(difference) > epsilonDifference) {
differences.push({
name: n.name,
total: {[IN]: explainSum(n, IN), [OUT]: explainSum(n, OUT)},
difference: withUnits(difference),
});
}
} else {
// Accumulate totals in & out of the graph
// (On this path, one of these values will be 0 every time.)
grandTotal[IN] += n.total[IN];
grandTotal[OUT] += n.total[OUT];
}
// Btw, check if this is a new maximum node:
if (n.value > maxNodeVal) {
maxNodeIndex = i;
maxNodeVal = n.value;
}
});
// Reflect summary stats to the user:
let totalsMsg
= `<strong>${approvedFlows.length} Flows</strong> between `
+ `<strong>${approvedNodes.length} Nodes</strong>. `;
// Do the totals match? If not, mention the different totals:
if (Math.abs(grandTotal[IN] - grandTotal[OUT]) > epsilonDifference) {
const gtLt = grandTotal[IN] > grandTotal[OUT] ? '>' : '<';
totalsMsg
+= `Total Inputs: <strong>${withUnits(grandTotal[IN])}</strong> ${gtLt}`
+ ` Total Outputs: <strong>${withUnits(grandTotal[OUT])}</strong>`;
} else {
totalsMsg += 'Total Inputs = Total Outputs = '
+ `<strong>${withUnits(grandTotal[IN])}</strong> ✅`;
}
msg.add(totalsMsg, 'total');
// Now that the SVG code has been generated, figure out this diagram's
// Scale & make that available to the user:
const tallestNodeHeight
= parseFloat(el(`r${maxNodeIndex}`).getAttributeNS(null, 'height')),
// Use <=2 decimal places to describe the tallest node's height:
formattedPixelCount = updateMarks(
d3.format(',.2~f')(tallestNodeHeight),
numberStyle.marks
),
// Show this value using the user's units, but override the number of
// decimal places to show 4 digits of precision:
unitsPerPixel = formatUserData(
maxNodeVal / tallestNodeHeight,
{...numberStyle, decimalPlaces: 4}
);
// All done. Give control back to the browser:
return null;
}
// 'glob' points to the global object, either 'window' (browser) or 'global' (node.js)
// This lets us contain everything in an IIFE (Immediately-Invoked Function Expression)
// el: shorthand for grabbing a DOM element, often to modify it
// elV: used if all we want is to READ the .value
function el(domId) {
return document.getElementById(domId);
}
function elV(domId) {
return document.getElementById(domId).value;
}
function outputFieldEl(fld) {
return el(`${fld}_val`);
}
// We store the breakpoint which means 'never' here for easy reference.
// When there are valid inputs, this is set to (stages count + 1).
let labelNeverBreakpoint = 9999;
// updateOutput: Called directly from the page.
// Given a field's name, update the visible value shown to the user.
const updateOutput = (fld) => {
const fldVal = elV(fld),
oEl = outputFieldEl(fld),
formats = {
node_h: '%',
node_spacing: '%',
node_opacity: '.2',
flow_curvature: '|',
flow_opacity: '.2',
labels_highlight: '.2',
labelposition_breakpoint: 'never',
};
switch (formats[fld]) {
case '|':
// 0.1 is treated as 0 for curvature. Display that:
if (fldVal <= 0.1) {
oEl.textContent = '0.00';
break;
}
// FALLS THROUGH to '.2' format when fldVal > 0.1:
case '.2':
oEl.textContent = d3.format('.2f')(fldVal);
break;
case '%':
oEl.textContent = `${fldVal}%`;
break;
case 'never':
oEl.textContent
= (Number(fldVal) === labelNeverBreakpoint ? '∅' : fldVal);
break;
default:
oEl.textContent = fldVal;
}
return null;
};
// isNumeric: borrowed from jQuery/Angular
function isNumeric(n) {
return !Number.isNaN(n - parseFloat(n));
}
// clamp: Ensure a numeric value n is between min and max.
// Default to min if not numeric.
function clamp(n, min, max) {
return isNumeric(n) ? Math.min(Math.max(n, min), max) : min;
}
// contrasting_gray_color:
// Given any hex color, return a grayscale color which is lower-contrast than
// pure black/white but still sufficient. (Used for less-important text.)
function contrasting_gray_color(hc) {
const c = d3.rgb(hc),
yiq = (c.r * 299 + c.g * 587 + c.b * 114) / 1000,
// Calculate a value sufficiently far away from this color.
// If it's bright-ish, make a dark gray; if dark-ish, make a light gray.
// This algorithm is far from exact! But it seems good enough.
// Lowest/highest values produced are 59 and 241.
gray = Math.floor(yiq > 164 ? (0.75 * yiq) - 64 : (0.30 * yiq) + 192);
return d3.rgb(gray, gray, gray);
}
// escapeHTML: make any input string safe to display.
// Used for displaying raw <SVG> code
// and for reflecting the user's input back to them in messages.
function escapeHTML(unsafeString) {
return unsafeString
.replaceAll('→', '→')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
.replaceAll('\n', '<br />');
}
// ep = "Enough Precision". Converts long decimals to have just 5 digits.
// Why?:
// SVG diagrams produced by SankeyMATIC don't really benefit from specifying
// values with more than 3 decimal places, but by default the output has *13*.
// This is frankly hard to read and actually inflates the size of the SVG
// output by quite a bit.
//
// Result: values like 216.7614485930364 become 216.76145 instead.
// The 'Number .. toString' call allows shortened output: 8 instead of 8.00000
function ep(x) {
return Number(x.toFixed(5)).toString();
}
// updateMarks: given a US-formatted number string, replace with user's
// preferred separators:
function updateMarks(stringIn, numberMarks) {
// If the digit-group mark is a comma, implicitly the decimal is a dot...
// That's what we start with, so return with no changes:
if (numberMarks.group === ',') {
return stringIn;
}
// Perform hacky mark swap using ! as a placeholder:
return stringIn.replaceAll(',', '!')
.replaceAll('.', numberMarks.decimal)
.replaceAll('!', numberMarks.group);
}
// formatUserData: produce a value in the user's designated format:
function formatUserData(numberIn, nStyle) {
const nString = updateMarks(
d3.format(`,.${nStyle.decimalPlaces}${nStyle.trimString}f`)(numberIn),
nStyle.marks
);
return `${nStyle.prefix}${nString}${nStyle.suffix}`;
}
// initializeDiagram: Reset the SVG tag to have the chosen size &
// background (with a pattern showing through if the user wants it to be
// transparent):
function initializeDiagram(cfg) {
const svgEl = el('sankey_svg');
svgEl.setAttribute('height', cfg.size_h);
svgEl.setAttribute('width', cfg.size_w);
svgEl.setAttribute(
'class',
`svg_background_${cfg.bg_transparent ? 'transparent' : 'default'}`
);
svgEl.textContent = ''; // Someday use replaceChildren() instead
}
// fileTimestamp() => 'yyyymmdd_hhmmss' for the current locale's time.
// Set up the formatting function once:
const formatTimestamp = d3.timeFormat('%Y%m%d_%H%M%S');
const fileTimestamp = () => formatTimestamp(new Date());
// humanTimestamp() => readable date in the current locale,
// e.g. "1/3/2023, 7:33:31 PM"
const humanTimestamp = () => new Date().toLocaleString();
// scaledPNG: Build a data URL for a PNG representing the current diagram:
function scaledPNG(scale) {
const chartEl = el('chart'),
orig = {w: chartEl.clientWidth, h: chartEl.clientHeight},
scaleFactor = clamp(scale, 1, 6),
scaled = {w: orig.w * scaleFactor, h: orig.h * scaleFactor},
// Canvg 3 needs interesting offsets added when scaling up:
offset = {
x: (scaled.w - orig.w) / (2 * scaleFactor),
y: (scaled.h - orig.h) / (2 * scaleFactor),
},
// Find the (hidden) canvas element in our page:
canvasEl = el('png_preview'),
canvasContext = canvasEl.getContext('2d'),
svgContent = (new XMLSerializer()).serializeToString(el('sankey_svg'));
// Set the canvas element to the final height/width the user wants.
// NOTE: THIS CAN FAIL. Canvases have maximum dimensions and a max area.
// TODO: Disable any export buttons which will fail silently.
canvasEl.width = scaled.w;
canvasEl.height = scaled.h;
// Give Canvg what it needs to produce a rendered image:
const canvgObj = Canvg.fromString(
canvasContext,
svgContent,
{
ignoreMouse: true,
ignoreAnimation: true,
ignoreDimensions: true, // DON'T make the canvas size match the svg
scaleWidth: scaled.w,
scaleHeight: scaled.h,
offsetX: offset.x,
offsetY: offset.y,
}
);
canvgObj.render();
// Turn canvg's output into a data URL and return it with size info:
return [scaled, canvasEl.toDataURL('image/png')];
}
// downloadABlob: given an object & a filename, send it to the user:
function downloadADataURL(dataURL, name) {
const newA = document.createElement('a');
newA.style.display = 'none';
newA.href = dataURL;
newA.download = name;
document.body.append(newA);
newA.click(); // This kicks off the download
newA.remove(); // Discard the Anchor we just clicked; it's no longer needed
}
function saveDiagramAsPNG (scale) {
const [size, pngURL] = scaledPNG(scale);
downloadADataURL(
pngURL,
`sankeymatic_${fileTimestamp()}_${size.w}x${size.h}.png`
);
};
// downloadATextFile: given a string & a filename, send it to the user:
function downloadATextFile(txt, name) {
const textBlob = new Blob([txt], {type: 'text/plain'}),
tempURL = URL.createObjectURL(textBlob);
downloadADataURL(tempURL, name);
URL.revokeObjectURL(tempURL);
}
// saveDiagramAsSVG: take the current state of 'sankey_svg' and relay
// it nicely to the user
const saveDiagramAsSVG = () => {
// Make a copy of the true SVG & make a few cosmetic changes:
const svgForExport
= el('sankey_svg').outerHTML
// Take out the id and the class declaration for the background:
.replace(' id="sankey_svg"', '')
.replace(/ class="svg_background_[a-z]+"/, '')
// Add a title placeholder & credit comment after the FIRST tag:
.replace(
/>/,
'>\r\n<title>Your Diagram Title</title>\r\n'
+ `<!-- Generated with SankeyMATIC: ${humanTimestamp()} -->\r\n`
)
// Add some line breaks to highlight where [g]roups start/end
// and where each path/text/rect begins:
.replace(/><(g|\/g|path|text|rect)/g, '>\r\n<$1');
downloadATextFile(svgForExport, `sankeymatic_${fileTimestamp()}.svg`);
};
// MARK SVG path specification functions
// flatFlowPathMaker(f):
// Returns an SVG path drawing a parallelogram between 2 nodes.
// Used for the "d" attribute on a "path" element when curvature = 0 OR
// when there is no curve to usefully draw (i.e. the flow is ~horizontal).
function flatFlowPathMaker(f) {
const sx = f.source.x + f.source.dx, // source's trailing edge
tx = f.target.x, // target's leading edge
syTop = f.source.y + f.sy, // source flow top
tyBot = f.target.y + f.ty + f.dy; // target flow bottom
f.renderAs = 'flat'; // Render this path as a filled parallelogram
// This SVG Path spec means:
// [M]ove to the flow source's top; draw a [v]ertical line down,
// a [L]ine to the opposite corner, a [v]ertical line up,
// then [z] close.
return `M${ep(sx)} ${ep(syTop)}v${ep(f.dy)}`
+ `L${ep(tx)} ${ep(tyBot)}v${ep(-f.dy)}z`;
}
// curvedFlowPathFunction(curvature):
// Returns an SVG-path-producing /function/ based on the given curvature.
// Used for the "d" attribute on a "path" element when curvature > 0.
// Defers to flatFlowPathMaker() when the flow is basically horizontal.
function curvedFlowPathFunction(curvature) {
return (f) => {
const syC = f.source.y + f.sy + f.dy / 2, // source flow's y center
tyC = f.target.y + f.ty + f.dy / 2, // target flow's y center
sEnd = f.source.x + f.source.dx, // source's trailing edge
tStart = f.target.x; // target's leading edge
// Watch out for a nearly-straight path (total rise/fall < 2 pixels OR
// very little horizontal space to work with).
// If we have one, make this flow a simple 4-sided shape instead of
// a curve. (This avoids weird artifacts in some SVG renderers.)
if (Math.abs(syC - tyC) < 2 || Math.abs(tStart - sEnd) < 12) {
return flatFlowPathMaker(f);
}
f.renderAs = 'curved'; // Render this path as a curved stroke
// Make the curved path:
// Set up a function for interpolating between the two x values:
const xinterpolate = d3.interpolateNumber(sEnd, tStart),
// Pick 2 curve control points given the curvature & its converse:
xcp1 = xinterpolate(curvature),
xcp2 = xinterpolate(1 - curvature);
// This SVG Path spec means:
// [M]ove to the center of the flow's start [sx,syC]
// Draw a Bezier [C]urve using control points [xcp1,syC] & [xcp2,tyC]
// End at the center of the flow's target [tx,tyC]
return (
`M${ep(sEnd)} ${ep(syC)}C${ep(xcp1)} ${ep(syC)} `
+ `${ep(xcp2)} ${ep(tyC)} ${ep(tStart)} ${ep(tyC)}`
);
};
}
// MARK Validation of Settings
// settingIsValid(metadata, human value, size object {w: _, h: _}):
// return [true, computer value] IF the given value meets the criteria.
// Note: The 'size' object is only used when validating 'contained' settings.
function settingIsValid(sData, hVal, cfg) {
const [dataType, defaultVal, allowList] = sData;
// Checkboxes: Translate y/n/Y/N/Yes/No to true/false.
if (dataType === 'yn' && reYesNo.test(hVal)) {
return [true, reYes.test(hVal)];
}
if (['radio', 'list'].includes(dataType)
&& allowList.includes(hVal)) {
return [true, hVal];
}
if (dataType === 'color') {
let rgb;
if (reRGBColor.test(hVal)) {
rgb = d3.rgb(hVal);
} else if (reBareColor.test(hVal)) {
rgb = d3.rgb(`#${hVal}`);
} else { // maybe it's a CSS name like blue/green/lime/maroon/etc.?
const namedRGB = d3.color(hVal);
if (namedRGB) {
rgb = namedRGB;
}
}
// If we found a real color spec, return the full 6-char html value.
// (This fixes the problem of a 3-character color like #789.)
if (rgb) {
return [true, rgb.formatHex()];
}
}
// valueInBounds: Verify a numeric value is in a range.
// 'max' can be undefined, which is treated as 'no maximum'
function valueInBounds(v, [min, max]) {
return v >= min && (max === undefined || v <= max);
}
if (dataType === 'text') {
// UN-double any single quotes:
const unescapedVal = hVal.replaceAll("''", "'");
// Make sure the string's length is in the right range:
if (valueInBounds(unescapedVal.length, allowList)) {
return [true, unescapedVal];
}
}
// The only types remaining are numbers:
const valAsNum = Number(hVal);
if (dataType === 'decimal'
&& reDecimal.test(hVal)
&& valueInBounds(valAsNum, [0, 1.0])) {
return [true, valAsNum];
}
if (['whole', 'contained', 'breakpoint'].includes(dataType)
&& reWholeNumber.test(hVal)) {
let [minV, maxV] = [0];
switch (dataType) {
case 'whole':
[minV, maxV] = allowList;
break;
// Dynamic values (like margins) should be processed after the
// diagram's size is set so that we can compare them to their
// specific containing dimension (that's why they appear later
// in the settings list):
case 'contained':
maxV = cfg[allowList[1]];
break;
// breakpoints: We can't just use the current 'never' value
// for comparison, since we may be importing a new diagram with
// a different number of stages:
case 'breakpoint':
maxV = defaultVal;
break;
// no default
}
if (valueInBounds(valAsNum, [minV, maxV])) {
return [true, valAsNum];
}
}
// If we could not affirmatively say this value is good:
return [false];
}
// MARK Message Display
// Show a value quoted & bolded & HTML-escaped:
function highlightSafeValue(userV) {
return `"<strong>${escapeHTML(userV)}</strong>"`;
}
// MARK Color Theme handling
// colorThemes: The available color arrays to assign to Nodes.
const colorThemes = new Map([
['a', {
colorset: d3.schemeCategory10,
nickname: 'Categories',
d3Name: 'Category10',
}],
['b', {
colorset: d3.schemeTableau10,
nickname: 'Tableau10',
d3Name: 'Tableau10',
}],