controlchartspy 0.1.0__py3-none-any.whl
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.
- controlchartspy/__init__.py +13 -0
- controlchartspy/_api.py +237 -0
- controlchartspy/_data.py +284 -0
- controlchartspy/_engine.py +97 -0
- controlchartspy/_limits.py +84 -0
- controlchartspy/_result.py +98 -0
- controlchartspy/_vendor/MISC.js +410 -0
- controlchartspy/_vendor/PBIFUN.js +4363 -0
- controlchartspy/_vendor/PBISPC.js +6253 -0
- controlchartspy/_vendor/ccD3.js +1545 -0
- controlchartspy/_vendor/commonUtils.js +241 -0
- controlchartspy/_vendor/headlessUtils.js +102 -0
- controlchartspy/_vendor/manifest.json +34 -0
- controlchartspy/_vendor/minidom.js +669 -0
- controlchartspy/py.typed +0 -0
- controlchartspy-0.1.0.dist-info/METADATA +275 -0
- controlchartspy-0.1.0.dist-info/RECORD +25 -0
- controlchartspy-0.1.0.dist-info/WHEEL +4 -0
- controlchartspy-0.1.0.dist-info/licenses/LICENSE +674 -0
- controlchartspy-0.1.0.dist-info/licenses/NOTICE +19 -0
- controlchartspy-0.1.0.dist-info/licenses/licenses/controlcharts-LICENSE.txt +21 -0
- controlchartspy-0.1.0.dist-info/licenses/licenses/d3-axis-LICENSE.txt +13 -0
- controlchartspy-0.1.0.dist-info/licenses/licenses/d3-path-LICENSE.txt +13 -0
- controlchartspy-0.1.0.dist-info/licenses/licenses/d3-selection-LICENSE.txt +13 -0
- controlchartspy-0.1.0.dist-info/licenses/licenses/d3-shape-LICENSE.txt +13 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Convert chart-engine results to R-compatible records."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._data import Record, Settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def spc_rows(rows: list[Record], settings: Settings) -> list[Record]:
|
|
9
|
+
rows = [row.get("table_row", row) for row in rows]
|
|
10
|
+
if not rows:
|
|
11
|
+
return []
|
|
12
|
+
flags = {
|
|
13
|
+
"astronomical": "astpoint",
|
|
14
|
+
"shift": "shift",
|
|
15
|
+
"trend": "trend",
|
|
16
|
+
"two_in_three": "two_in_three",
|
|
17
|
+
}
|
|
18
|
+
excluded = {
|
|
19
|
+
column for flag, column in flags.items() if not settings.get(flag, False)
|
|
20
|
+
}
|
|
21
|
+
columns: list[str] = []
|
|
22
|
+
for row in rows:
|
|
23
|
+
for name, value in row.items():
|
|
24
|
+
if value is not None and name not in excluded and name not in columns:
|
|
25
|
+
columns.append(name)
|
|
26
|
+
result = []
|
|
27
|
+
for row in rows:
|
|
28
|
+
record: Record = {name: row.get(name) for name in columns}
|
|
29
|
+
record["date"] = record["date"].strip()
|
|
30
|
+
result.append(record)
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def spc_limits(raw: Record, settings: Settings) -> list[Record] | dict[str, Any]:
|
|
35
|
+
if not raw["indicatorVarNames"]:
|
|
36
|
+
return spc_rows(raw["plotPoints"], settings)
|
|
37
|
+
result: dict[str, Any] = {}
|
|
38
|
+
for groups, rows in zip(raw["groupNames"], raw["spcLimitRows"]):
|
|
39
|
+
names = []
|
|
40
|
+
for name in groups:
|
|
41
|
+
if name is None or name == "":
|
|
42
|
+
name = "<blank>"
|
|
43
|
+
names.append(str(name))
|
|
44
|
+
branch = result
|
|
45
|
+
for name in names[:-1]:
|
|
46
|
+
branch = branch.setdefault(name, {})
|
|
47
|
+
branch[names[-1]] = spc_rows(rows, settings)
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def funnel_lines(raw: Record, settings: Settings) -> list[Record]:
|
|
52
|
+
result = []
|
|
53
|
+
for row in raw["calculatedLimits"]:
|
|
54
|
+
record = {"denominator": row["denominators"]}
|
|
55
|
+
for name, value in row.items():
|
|
56
|
+
if name == "denominators":
|
|
57
|
+
continue
|
|
58
|
+
if name == "alt_target" and settings.get("alt_target") is None:
|
|
59
|
+
continue
|
|
60
|
+
record[name] = value
|
|
61
|
+
result.append(record)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def funnel_limits(raw: Record, lines: list[Record], settings: Settings) -> list[Record]:
|
|
66
|
+
by_denominator: dict[Any, list[Record]] = {}
|
|
67
|
+
for row in lines:
|
|
68
|
+
by_denominator.setdefault(row["denominator"], []).append(row)
|
|
69
|
+
result = []
|
|
70
|
+
for point in raw["plotPoints"]:
|
|
71
|
+
for line in by_denominator.get(point["x"], []):
|
|
72
|
+
record = {
|
|
73
|
+
"denominator": point["x"],
|
|
74
|
+
"group": point["group_text"],
|
|
75
|
+
"numerator": point["numerator"],
|
|
76
|
+
"value": point["value"],
|
|
77
|
+
"z": point["z"],
|
|
78
|
+
}
|
|
79
|
+
for flag in ("two_sigma", "three_sigma"):
|
|
80
|
+
if settings.get(flag, False):
|
|
81
|
+
record[flag] = point[flag]
|
|
82
|
+
record.update(line)
|
|
83
|
+
result.append(record)
|
|
84
|
+
return sorted(result, key=lambda row: row["denominator"])
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Chart results and standalone SVG export."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from numbers import Real
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._data import Record
|
|
11
|
+
from ._engine import render
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dimension(value: object, default: float) -> float:
|
|
15
|
+
if value is None:
|
|
16
|
+
return default
|
|
17
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
18
|
+
raise TypeError("Chart dimensions must be positive numbers.")
|
|
19
|
+
if not math.isfinite(value) or value <= 0:
|
|
20
|
+
raise ValueError("Chart dimensions must be positive numbers.")
|
|
21
|
+
return float(value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def svg_string(svg: str, width: float, height: float) -> str:
|
|
25
|
+
svg = svg.replace('dominant-baseline="hanging"', 'dy="0.8em"')
|
|
26
|
+
svg = svg.replace('dominant-baseline="middle"', 'dy="0.35em"')
|
|
27
|
+
return (
|
|
28
|
+
f'<svg viewBox="0 0 {width:g} {height:g}" width="{width:g}px" '
|
|
29
|
+
f'height="{height:g}px" xmlns="http://www.w3.org/2000/svg">'
|
|
30
|
+
'<rect x="0" y="0" width="100%" height="100%" fill="white"/>'
|
|
31
|
+
f"{svg}</svg>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class StaticPlot:
|
|
37
|
+
"""A static SVG plot with its dimensions and rendering settings."""
|
|
38
|
+
|
|
39
|
+
type: str
|
|
40
|
+
svg: str
|
|
41
|
+
width: float
|
|
42
|
+
height: float
|
|
43
|
+
data_views: list[Any] = field(repr=False)
|
|
44
|
+
title_settings: dict[str, Any] = field(repr=False)
|
|
45
|
+
|
|
46
|
+
def to_svg(self, width: float | None = None, height: float | None = None) -> str:
|
|
47
|
+
"""Return a complete SVG document, optionally rendered at a new size."""
|
|
48
|
+
svg = self.svg
|
|
49
|
+
if width is not None or height is not None:
|
|
50
|
+
width = dimension(width, self.width)
|
|
51
|
+
height = dimension(height, self.height)
|
|
52
|
+
svg = render(
|
|
53
|
+
self.type,
|
|
54
|
+
self.data_views,
|
|
55
|
+
self.title_settings,
|
|
56
|
+
width,
|
|
57
|
+
height,
|
|
58
|
+
True,
|
|
59
|
+
False,
|
|
60
|
+
)["svg"]
|
|
61
|
+
else:
|
|
62
|
+
width = self.width
|
|
63
|
+
height = self.height
|
|
64
|
+
return svg_string(svg, width, height)
|
|
65
|
+
|
|
66
|
+
def _repr_svg_(self) -> str:
|
|
67
|
+
return self.to_svg()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ControlChart:
|
|
72
|
+
"""A control chart containing the requested plot and calculated limits."""
|
|
73
|
+
|
|
74
|
+
static_plot: StaticPlot | None = None
|
|
75
|
+
limits: list[Record] | dict[str, Any] | None = None
|
|
76
|
+
limit_lines: list[Record] | None = None
|
|
77
|
+
indicator_names: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def save_plot(
|
|
80
|
+
self,
|
|
81
|
+
file: str | os.PathLike[str],
|
|
82
|
+
width: float | None = None,
|
|
83
|
+
height: float | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Save the static plot as SVG, optionally rendering at a new size."""
|
|
86
|
+
if self.static_plot is None:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"This object has no static plot; request 'static_plot' in return_objs."
|
|
89
|
+
)
|
|
90
|
+
path = Path(file)
|
|
91
|
+
if path.suffix.lower() != ".svg":
|
|
92
|
+
raise ValueError("Only SVG output is supported.")
|
|
93
|
+
path.write_text(self.static_plot.to_svg(width, height) + "\n", encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
def _repr_svg_(self) -> str | None:
|
|
96
|
+
if self.static_plot is not None:
|
|
97
|
+
return self.static_plot.to_svg()
|
|
98
|
+
return None
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
var misc = (function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const defaultSettings = {
|
|
5
|
+
canvas: {
|
|
6
|
+
upper_padding: 80,
|
|
7
|
+
right_padding: 20,
|
|
8
|
+
lower_padding: 45,
|
|
9
|
+
left_padding: 220,
|
|
10
|
+
background_colour: "#FFFFFF",
|
|
11
|
+
font_family: "Arial, sans-serif"
|
|
12
|
+
},
|
|
13
|
+
misc: {
|
|
14
|
+
source: "",
|
|
15
|
+
unfavourable_label: "Unfavourable",
|
|
16
|
+
favourable_label: "Favourable",
|
|
17
|
+
unfavourable_colour: "#E46C0A",
|
|
18
|
+
favourable_colour: "#00B0F0",
|
|
19
|
+
direction_font_size: 12
|
|
20
|
+
},
|
|
21
|
+
bars: {
|
|
22
|
+
colour_deterioration: "#E46C0A",
|
|
23
|
+
colour_none: "#A6A6A6",
|
|
24
|
+
colour_improvement: "#00B0F0",
|
|
25
|
+
height_ratio: 0.65,
|
|
26
|
+
opacity: 1
|
|
27
|
+
},
|
|
28
|
+
lines: {
|
|
29
|
+
colour: "#00667B",
|
|
30
|
+
width: 1.5,
|
|
31
|
+
show_95: true,
|
|
32
|
+
show_99: true,
|
|
33
|
+
show_zero: true,
|
|
34
|
+
lower_95: -1.959963984540054,
|
|
35
|
+
upper_95: 1.959963984540054,
|
|
36
|
+
lower_99: -3.090232306167813,
|
|
37
|
+
upper_99: 3.090232306167813,
|
|
38
|
+
type_95: "2,2",
|
|
39
|
+
type_99: "6,4"
|
|
40
|
+
},
|
|
41
|
+
x_axis: {
|
|
42
|
+
xlimit_l: -5,
|
|
43
|
+
xlimit_u: 5,
|
|
44
|
+
xlimit_tick_interval: 1,
|
|
45
|
+
xlimit_tick_size: 10,
|
|
46
|
+
xlimit_tick_font: "Arial, sans-serif",
|
|
47
|
+
xlimit_colour: "#000000",
|
|
48
|
+
xlimit_grid_show: true,
|
|
49
|
+
xlimit_grid_colour: "#E5E5E5",
|
|
50
|
+
xlimit_label: ""
|
|
51
|
+
},
|
|
52
|
+
y_axis: {
|
|
53
|
+
ylimit_tick_size: 11,
|
|
54
|
+
ylimit_tick_font: "Arial, sans-serif",
|
|
55
|
+
ylimit_colour: "#000000",
|
|
56
|
+
group_tick_size: 11,
|
|
57
|
+
group_font_weight: "bold"
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function valueColumn(view, role) {
|
|
62
|
+
return (view.values || []).find(column => column.source.roles?.[role]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tooltipColumns(view) {
|
|
66
|
+
return (view.values || []).filter(column => column.source.roles?.tooltips);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function columnValues(column) {
|
|
70
|
+
if (!column) return [];
|
|
71
|
+
return Array.isArray(column.values) ? column.values : [column.values];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mergeSettings(objects) {
|
|
75
|
+
const settings = {};
|
|
76
|
+
Object.keys(defaultSettings).forEach(group => {
|
|
77
|
+
settings[group] = Object.assign({}, defaultSettings[group], objects?.[group] || {});
|
|
78
|
+
});
|
|
79
|
+
return settings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatNumber(value, digits) {
|
|
83
|
+
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
return Number(value).toFixed(digits);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pointColour(point) {
|
|
90
|
+
const bars = point.settings.bars;
|
|
91
|
+
if (point.outlier === "improvement") return bars.colour_improvement;
|
|
92
|
+
if (point.outlier === "deterioration") return bars.colour_deterioration;
|
|
93
|
+
return bars.colour_none;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeTooltip(point, extras) {
|
|
97
|
+
const value = value => `${formatNumber(value, point.sigFigs)}${point.suffix}`;
|
|
98
|
+
const countDigits = point.suffix === "%" ? 0 : point.sigFigs;
|
|
99
|
+
const items = [
|
|
100
|
+
{ displayName: "Indicator", value: point.indicator },
|
|
101
|
+
{ displayName: "Group", value: point.group },
|
|
102
|
+
{ displayName: "Z-score", value: formatNumber(point.score, 3) },
|
|
103
|
+
{ displayName: "Numerator", value: formatNumber(point.numerator, countDigits) },
|
|
104
|
+
{ displayName: "Denominator", value: formatNumber(point.denominator, countDigits) },
|
|
105
|
+
{ displayName: "Actual Value", value: value(point.value) },
|
|
106
|
+
{ displayName: "Upper 99.8% Limit", value: value(point.ul99) },
|
|
107
|
+
{ displayName: "Centerline", value: value(point.target) },
|
|
108
|
+
{ displayName: "Lower 99.8% Limit", value: value(point.ll99) }
|
|
109
|
+
];
|
|
110
|
+
extras.forEach(extra => items.push(extra));
|
|
111
|
+
return items;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class MiscViewModel {
|
|
115
|
+
constructor() {
|
|
116
|
+
this.plotPoints = [];
|
|
117
|
+
this.svgWidth = 0;
|
|
118
|
+
this.svgHeight = 0;
|
|
119
|
+
this.settings = mergeSettings(null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
update(options, host) {
|
|
123
|
+
this.svgWidth = options.viewport.width;
|
|
124
|
+
this.svgHeight = options.viewport.height;
|
|
125
|
+
this.plotPoints = [];
|
|
126
|
+
const view = options.dataViews?.[0]?.categorical;
|
|
127
|
+
if (!view || !view.categories?.[0]) {
|
|
128
|
+
return { status: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const category = view.categories[0];
|
|
132
|
+
const columns = {
|
|
133
|
+
groupings: valueColumn(view, "groupings"),
|
|
134
|
+
groups: valueColumn(view, "groups"),
|
|
135
|
+
scores: valueColumn(view, "scores"),
|
|
136
|
+
zScores: valueColumn(view, "z_scores"),
|
|
137
|
+
outliers: valueColumn(view, "outliers"),
|
|
138
|
+
numerators: valueColumn(view, "numerators"),
|
|
139
|
+
denominators: valueColumn(view, "denominators"),
|
|
140
|
+
values: valueColumn(view, "values"),
|
|
141
|
+
ll99: valueColumn(view, "ll99"),
|
|
142
|
+
targets: valueColumn(view, "targets"),
|
|
143
|
+
ul99: valueColumn(view, "ul99"),
|
|
144
|
+
suffixes: valueColumn(view, "suffixes"),
|
|
145
|
+
sigFigs: valueColumn(view, "sig_figs")
|
|
146
|
+
};
|
|
147
|
+
if (!columns.scores) {
|
|
148
|
+
return { status: false, error: "MISC scores are required." };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const extras = tooltipColumns(view);
|
|
152
|
+
const categoryValues = columnValues(category);
|
|
153
|
+
const values = Object.fromEntries(
|
|
154
|
+
Object.entries(columns).map(([name, column]) => [name, columnValues(column)])
|
|
155
|
+
);
|
|
156
|
+
const extraValues = extras.map(columnValues);
|
|
157
|
+
for (let i = 0; i < categoryValues.length; i++) {
|
|
158
|
+
const settings = mergeSettings(category.objects?.[i]);
|
|
159
|
+
const extraItems = extras.map((column, j) => ({
|
|
160
|
+
displayName: column.source.displayName,
|
|
161
|
+
value: extraValues[j][i]
|
|
162
|
+
}));
|
|
163
|
+
const point = {
|
|
164
|
+
indicator: categoryValues[i],
|
|
165
|
+
grouping: values.groupings[i] ?? "",
|
|
166
|
+
group: values.groups[i] ?? "",
|
|
167
|
+
score: Number(values.scores[i]),
|
|
168
|
+
z: Number(values.zScores[i]),
|
|
169
|
+
outlier: values.outliers[i] ?? "none",
|
|
170
|
+
numerator: values.numerators[i],
|
|
171
|
+
denominator: values.denominators[i],
|
|
172
|
+
value: values.values[i],
|
|
173
|
+
ll99: values.ll99[i],
|
|
174
|
+
target: values.targets[i],
|
|
175
|
+
ul99: values.ul99[i],
|
|
176
|
+
suffix: values.suffixes[i] ?? "",
|
|
177
|
+
sigFigs: Number(values.sigFigs[i] ?? 2),
|
|
178
|
+
settings: settings,
|
|
179
|
+
identity: host.createSelectionIdBuilder()
|
|
180
|
+
.withCategory(category, i)
|
|
181
|
+
.createSelectionId()
|
|
182
|
+
};
|
|
183
|
+
point.tooltip = makeTooltip(point, extraItems);
|
|
184
|
+
this.plotPoints.push(point);
|
|
185
|
+
}
|
|
186
|
+
this.settings = this.plotPoints[0]?.settings ?? mergeSettings(null);
|
|
187
|
+
return { status: true };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
class Visual {
|
|
192
|
+
constructor(options) {
|
|
193
|
+
this.host = options.host;
|
|
194
|
+
this.selectionManager = options.host.createSelectionManager();
|
|
195
|
+
this.svg = ccD3.select(options.element).append("svg");
|
|
196
|
+
this.viewModel = new MiscViewModel();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
update(options) {
|
|
200
|
+
const status = this.viewModel.update(options, this.host);
|
|
201
|
+
this.svg
|
|
202
|
+
.attr("width", options.viewport.width)
|
|
203
|
+
.attr("height", options.viewport.height)
|
|
204
|
+
.style("background", this.viewModel.settings.canvas.background_colour);
|
|
205
|
+
this.svg.selectAll(".misc-root, .errormessage").remove();
|
|
206
|
+
if (!status.status) {
|
|
207
|
+
this.svg.append("text")
|
|
208
|
+
.classed("errormessage", true)
|
|
209
|
+
.attr("x", 10)
|
|
210
|
+
.attr("y", 20)
|
|
211
|
+
.text(status.error);
|
|
212
|
+
return status;
|
|
213
|
+
}
|
|
214
|
+
if (this.viewModel.plotPoints.length === 0) return status;
|
|
215
|
+
this.draw();
|
|
216
|
+
return status;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
draw() {
|
|
220
|
+
const points = this.viewModel.plotPoints;
|
|
221
|
+
const settings = this.viewModel.settings;
|
|
222
|
+
const canvas = settings.canvas;
|
|
223
|
+
const axis = settings.x_axis;
|
|
224
|
+
const width = this.viewModel.svgWidth;
|
|
225
|
+
const height = this.viewModel.svgHeight;
|
|
226
|
+
const left = canvas.left_padding;
|
|
227
|
+
const right = width - canvas.right_padding;
|
|
228
|
+
const top = canvas.upper_padding;
|
|
229
|
+
const bottom = height - canvas.lower_padding;
|
|
230
|
+
const plotWidth = Math.max(1, right - left);
|
|
231
|
+
const plotHeight = Math.max(1, bottom - top);
|
|
232
|
+
// Each group heading occupies its own row above its indicators
|
|
233
|
+
const rowIndex = new Array(points.length);
|
|
234
|
+
let rows = 0;
|
|
235
|
+
for (let i = 0; i < points.length; i++) {
|
|
236
|
+
if (points[i].grouping && points[i].grouping !== points[i - 1]?.grouping) rows++;
|
|
237
|
+
rowIndex[i] = rows++;
|
|
238
|
+
}
|
|
239
|
+
const rowHeight = plotHeight / rows;
|
|
240
|
+
const lower = axis.xlimit_l;
|
|
241
|
+
const upper = axis.xlimit_u;
|
|
242
|
+
const scale = value => left + ((value - lower) / (upper - lower)) * plotWidth;
|
|
243
|
+
const root = this.svg.append("g").classed("misc-root", true);
|
|
244
|
+
|
|
245
|
+
if (axis.xlimit_grid_show) {
|
|
246
|
+
for (let tick = lower; tick <= upper; tick += axis.xlimit_tick_interval) {
|
|
247
|
+
root.append("line")
|
|
248
|
+
.classed("misc-grid-line", true)
|
|
249
|
+
.attr("x1", scale(tick)).attr("x2", scale(tick))
|
|
250
|
+
.attr("y1", top).attr("y2", bottom)
|
|
251
|
+
.attr("stroke", axis.xlimit_grid_colour);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.drawControlLines(root, scale, top, bottom, settings.lines);
|
|
256
|
+
root.append("line")
|
|
257
|
+
.classed("misc-axis", true)
|
|
258
|
+
.attr("x1", left).attr("x2", right)
|
|
259
|
+
.attr("y1", bottom).attr("y2", bottom)
|
|
260
|
+
.attr("stroke", axis.xlimit_colour);
|
|
261
|
+
|
|
262
|
+
for (let tick = lower; tick <= upper; tick += axis.xlimit_tick_interval) {
|
|
263
|
+
root.append("text")
|
|
264
|
+
.classed("misc-x-tick", true)
|
|
265
|
+
.attr("x", scale(tick)).attr("y", bottom + axis.xlimit_tick_size + 5)
|
|
266
|
+
.attr("text-anchor", "middle")
|
|
267
|
+
.style("font-family", axis.xlimit_tick_font)
|
|
268
|
+
.style("font-size", `${axis.xlimit_tick_size}px`)
|
|
269
|
+
.style("fill", axis.xlimit_colour)
|
|
270
|
+
.text(tick);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const zero = scale(0);
|
|
274
|
+
const bars = root.selectAll(".misc-bar")
|
|
275
|
+
.data(points)
|
|
276
|
+
.join("rect")
|
|
277
|
+
.classed("misc-bar", true)
|
|
278
|
+
.attr("x", point => scale(Math.min(0, Math.max(lower, Math.min(upper, point.score)))))
|
|
279
|
+
.attr("y", (_, i) => top + rowIndex[i] * rowHeight + rowHeight * (1 - settings.bars.height_ratio) / 2)
|
|
280
|
+
.attr("width", point => Math.abs(scale(Math.max(lower, Math.min(upper, point.score))) - zero))
|
|
281
|
+
.attr("height", rowHeight * settings.bars.height_ratio)
|
|
282
|
+
.attr("fill", pointColour)
|
|
283
|
+
.attr("opacity", point => point.settings.bars.opacity);
|
|
284
|
+
|
|
285
|
+
bars.on("mouseover", (event, point) => {
|
|
286
|
+
this.host.tooltipService.show({
|
|
287
|
+
coordinates: [event.offsetX ?? 0, event.offsetY ?? 0],
|
|
288
|
+
dataItems: point.tooltip,
|
|
289
|
+
identities: [point.identity]
|
|
290
|
+
});
|
|
291
|
+
}).on("mouseout", () => this.host.tooltipService.hide());
|
|
292
|
+
|
|
293
|
+
points.forEach((point, i) => {
|
|
294
|
+
const y = top + (rowIndex[i] + 0.5) * rowHeight;
|
|
295
|
+
root.append("text")
|
|
296
|
+
.classed("misc-y-tick", true)
|
|
297
|
+
.attr("x", left - 8).attr("y", y)
|
|
298
|
+
.attr("text-anchor", "end")
|
|
299
|
+
.attr("dominant-baseline", "middle")
|
|
300
|
+
.style("font-family", settings.y_axis.ylimit_tick_font)
|
|
301
|
+
.style("font-size", `${settings.y_axis.ylimit_tick_size}px`)
|
|
302
|
+
.style("fill", settings.y_axis.ylimit_colour)
|
|
303
|
+
.text(point.indicator);
|
|
304
|
+
if (point.grouping && point.grouping !== points[i - 1]?.grouping) {
|
|
305
|
+
root.append("text")
|
|
306
|
+
.classed("misc-group-label", true)
|
|
307
|
+
.attr("x", 5).attr("y", y - rowHeight)
|
|
308
|
+
.attr("dominant-baseline", "middle")
|
|
309
|
+
.style("font-family", settings.y_axis.ylimit_tick_font)
|
|
310
|
+
.style("font-size", `${settings.y_axis.group_tick_size}px`)
|
|
311
|
+
.style("font-weight", settings.y_axis.group_font_weight)
|
|
312
|
+
.style("fill", settings.y_axis.ylimit_colour)
|
|
313
|
+
.text(point.grouping);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
this.drawHeader(root, scale, top, settings);
|
|
318
|
+
if (axis.xlimit_label) {
|
|
319
|
+
root.append("text")
|
|
320
|
+
.classed("misc-x-label", true)
|
|
321
|
+
.attr("x", left).attr("y", height - 5)
|
|
322
|
+
.style("font-family", canvas.font_family)
|
|
323
|
+
.style("font-size", "10px")
|
|
324
|
+
.text(axis.xlimit_label);
|
|
325
|
+
}
|
|
326
|
+
if (settings.misc.source) {
|
|
327
|
+
root.append("text")
|
|
328
|
+
.classed("misc-source", true)
|
|
329
|
+
.attr("x", right).attr("y", height - 5)
|
|
330
|
+
.attr("text-anchor", "end")
|
|
331
|
+
.style("font-family", canvas.font_family)
|
|
332
|
+
.style("font-size", "10px")
|
|
333
|
+
.style("font-style", "italic")
|
|
334
|
+
.text(settings.misc.source);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
drawControlLines(root, scale, top, bottom, lines) {
|
|
339
|
+
const draw = (value, cssClass, dash) => root.append("line")
|
|
340
|
+
.classed(cssClass, true)
|
|
341
|
+
.attr("x1", scale(value)).attr("x2", scale(value))
|
|
342
|
+
.attr("y1", top).attr("y2", bottom)
|
|
343
|
+
.attr("stroke", lines.colour)
|
|
344
|
+
.attr("stroke-width", lines.width)
|
|
345
|
+
.attr("stroke-dasharray", dash);
|
|
346
|
+
if (lines.show_99) {
|
|
347
|
+
draw(lines.lower_99, "misc-limit-99", lines.type_99);
|
|
348
|
+
draw(lines.upper_99, "misc-limit-99", lines.type_99);
|
|
349
|
+
}
|
|
350
|
+
if (lines.show_95) {
|
|
351
|
+
draw(lines.lower_95, "misc-limit-95", lines.type_95);
|
|
352
|
+
draw(lines.upper_95, "misc-limit-95", lines.type_95);
|
|
353
|
+
}
|
|
354
|
+
if (lines.show_zero) draw(0, "misc-zero-line", null);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
drawHeader(root, scale, top, settings) {
|
|
358
|
+
const miscSettings = settings.misc;
|
|
359
|
+
const lines = settings.lines;
|
|
360
|
+
root.append("text")
|
|
361
|
+
.classed("misc-direction-unfavourable", true)
|
|
362
|
+
.attr("x", scale(-4)).attr("y", top - 10)
|
|
363
|
+
.attr("text-anchor", "middle")
|
|
364
|
+
.style("font-family", settings.canvas.font_family)
|
|
365
|
+
.style("font-size", `${miscSettings.direction_font_size}px`)
|
|
366
|
+
.style("font-weight", "bold")
|
|
367
|
+
.style("font-style", "italic")
|
|
368
|
+
.style("fill", miscSettings.unfavourable_colour)
|
|
369
|
+
.text(miscSettings.unfavourable_label);
|
|
370
|
+
root.append("text")
|
|
371
|
+
.classed("misc-direction-favourable", true)
|
|
372
|
+
.attr("x", scale(4)).attr("y", top - 10)
|
|
373
|
+
.attr("text-anchor", "middle")
|
|
374
|
+
.style("font-family", settings.canvas.font_family)
|
|
375
|
+
.style("font-size", `${miscSettings.direction_font_size}px`)
|
|
376
|
+
.style("font-weight", "bold")
|
|
377
|
+
.style("font-style", "italic")
|
|
378
|
+
.style("fill", miscSettings.favourable_colour)
|
|
379
|
+
.text(miscSettings.favourable_label);
|
|
380
|
+
|
|
381
|
+
const legendY = top - 35;
|
|
382
|
+
const entries = [
|
|
383
|
+
{ show: lines.show_95, label: "95% Control Limits", dash: lines.type_95 },
|
|
384
|
+
{ show: lines.show_99, label: "99.8% Control Limits", dash: lines.type_99 }
|
|
385
|
+
].filter(entry => entry.show);
|
|
386
|
+
entries.forEach((entry, i) => {
|
|
387
|
+
const x = scale(-1.2 + i * 2.2);
|
|
388
|
+
root.append("line")
|
|
389
|
+
.attr("x1", x).attr("x2", x + 20)
|
|
390
|
+
.attr("y1", legendY).attr("y2", legendY)
|
|
391
|
+
.attr("stroke", lines.colour)
|
|
392
|
+
.attr("stroke-width", lines.width)
|
|
393
|
+
.attr("stroke-dasharray", entry.dash);
|
|
394
|
+
root.append("text")
|
|
395
|
+
.attr("x", x + 24).attr("y", legendY)
|
|
396
|
+
.attr("dominant-baseline", "middle")
|
|
397
|
+
.style("font-family", settings.canvas.font_family)
|
|
398
|
+
.style("font-size", "10px")
|
|
399
|
+
.text(entry.label);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
updateHighlighting() {}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
Visual: Visual,
|
|
408
|
+
defaultSettings: defaultSettings
|
|
409
|
+
};
|
|
410
|
+
})();
|