@torpor/ui 0.3.6 → 0.3.7

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.
@@ -0,0 +1,159 @@
1
+ import type { ClassValue, StyleValue } from "@torpor/view";
2
+ import XAxis from "./XAxis.torp";
3
+ import YAxis from "./YAxis.torp";
4
+ import GridLines from "./GridLines.torp";
5
+ import {
6
+ calculateMaxValue,
7
+ calculateStepValue,
8
+ calculateStepLabels,
9
+ calculateItemWidth,
10
+ calculateValueHeight,
11
+ calculateChartBottom,
12
+ calculateChartLeft
13
+ } from "../utils/chartUtils";
14
+ import { chartColors } from "../utils/chartColors";
15
+
16
+ import type DataSeries from "./DataSeries";
17
+
18
+ interface BarChartProps {
19
+ /** An ID for the root element */
20
+ id?: string;
21
+ /** Classes for the root element */
22
+ class?: ClassValue,
23
+ /** Styles for the root element */
24
+ style?: StyleValue,
25
+ series: DataSeries[],
26
+ height?: number,
27
+ width?: number,
28
+ xLabel?: string,
29
+ yLabel?: string,
30
+ maxValue?: number,
31
+ stepCount?: number,
32
+ stepValue?: number,
33
+ hideXAxis?: boolean,
34
+ hideYAxis?: boolean,
35
+ showHLines?: boolean,
36
+ showVLines?: boolean,
37
+ }
38
+
39
+ export default function BarChart($props: BarChartProps) {
40
+ const defaultHeight = 200;
41
+ const defaultWidth = 400;
42
+
43
+ let container: HTMLElement;
44
+ let measurer: SVGTextElement;
45
+
46
+ let $state = $watch({
47
+ textWidth: 0,
48
+ textHeight: 0,
49
+ get labels() {
50
+ return $props.series.map((s) => s.name);
51
+ },
52
+ get calculatedHeight() {
53
+ return $props.height || defaultHeight;
54
+ },
55
+ get calculatedWidth() {
56
+ return $props.width || container?.clientWidth || defaultWidth;
57
+ },
58
+ get maxValue () {
59
+ return $props.maxValue || calculateMaxValue($props.series);
60
+ },
61
+ get calculatedStepValue() {
62
+ return $props.stepValue || calculateStepValue($state.maxValue, $props.stepCount);
63
+ },
64
+ get stepLabels() {
65
+ return calculateStepLabels($props.stepCount || 2, $state.calculatedStepValue);
66
+ },
67
+ get itemWidth() {
68
+ return calculateItemWidth($state.calculatedWidth, $state.chartLeft, $state.labels);
69
+ },
70
+ get valueHeight() {
71
+ return calculateValueHeight($state.chartBottom, $state.textHeight, $state.calculatedStepValue, $props.stepCount || 2);
72
+ },
73
+ get chartBottom() {
74
+ return calculateChartBottom($props.xLabel ?? "", $state.calculatedHeight, $state.textHeight);
75
+ },
76
+ get chartLeft() {
77
+ return calculateChartLeft($state.stepLabels, $props.yLabel ?? "", $state.textHeight, $state.textWidth);
78
+ }
79
+ });
80
+
81
+ $mount(() => {
82
+ // HACK: Wait until the measurer is really available
83
+ setTimeout(() => {
84
+ const bbox = measurer.getBBox();
85
+ $state.textWidth = bbox.width;
86
+ $state.textHeight = bbox.height * 1.5;
87
+ measurer.remove();
88
+ }, 1);
89
+ });
90
+
91
+ @render {
92
+ <div
93
+ {$props?.id}
94
+ class={["torp-bar-chart", $props?.class]}
95
+ {$props?.style}
96
+ &ref={container}
97
+ >
98
+ <svg version="1.1" width={$state.calculatedWidth} height={$state.calculatedHeight} xmlns="http://www.w3.org/2000/svg">
99
+ <g>
100
+ <text &ref={measurer} x="-999" y="-999">8</text>
101
+
102
+ @if ($state.textWidth && $state.textHeight) {
103
+ <XAxis
104
+ showAxis={!$props.hideXAxis}
105
+ height={$state.calculatedHeight}
106
+ width={$state.calculatedWidth}
107
+ {$state.itemWidth}
108
+ {$state.textHeight}
109
+ xLabel={$props.xLabel ?? ""}
110
+ stepLabels={[]}
111
+ stepValue={0}
112
+ {$state.labels}
113
+ {$state.chartLeft}
114
+ {$state.chartBottom}
115
+ />
116
+ <YAxis
117
+ showAxis={!$props.hideYAxis}
118
+ {$state.valueHeight}
119
+ {$state.textHeight}
120
+ yLabel={$props.yLabel ?? ""}
121
+ {$state.stepLabels}
122
+ stepValue={$state.calculatedStepValue}
123
+ {$state.chartLeft}
124
+ {$state.chartBottom}
125
+ />
126
+ <GridLines
127
+ {$props.showHLines}
128
+ {$props.showVLines}
129
+ width={$state.calculatedWidth}
130
+ {$state.labels}
131
+ xStepValue={0}
132
+ xStepLabels={[]}
133
+ yStepValue={$state.calculatedStepValue}
134
+ yStepLabels={$state.stepLabels}
135
+ {$state.itemWidth}
136
+ {$state.valueHeight}
137
+ {$state.chartLeft}
138
+ {$state.chartBottom}
139
+ type="bar"
140
+ />
141
+ @for (let [i, series] of $props.series.entries()) {
142
+ <rect
143
+ x={$state.chartLeft +
144
+ i * $state.itemWidth +
145
+ $state.itemWidth / 4}
146
+ y={$state.chartBottom - Math.max(1, series.data[0] * $state.valueHeight)}
147
+ width={Math.max(1, $state.itemWidth / 2)}
148
+ height={Math.max(1, series.data[0] * $state.valueHeight)}
149
+ fill={series.color || chartColors[i % chartColors.length]}
150
+ >
151
+ <title>{`${[series.name, series.data[0]].filter(Boolean).join("\n")}`}</title>
152
+ </rect>
153
+ }
154
+ }
155
+ </g>
156
+ </svg>
157
+ </div>
158
+ }
159
+ }
@@ -0,0 +1,5 @@
1
+ export default interface DataSeries {
2
+ name: string;
3
+ color: string;
4
+ data: number[];
5
+ }
@@ -0,0 +1,62 @@
1
+ import type { ClassValue, StyleValue } from "@torpor/view";
2
+
3
+ interface GridLinesProps {
4
+ /** An ID for the root element */
5
+ id?: string;
6
+ /** Classes for the root element */
7
+ class?: ClassValue,
8
+ /** Styles for the root element */
9
+ style?: StyleValue,
10
+ showHLines?: boolean,
11
+ showVLines?: boolean,
12
+ width: number;
13
+ labels: string[];
14
+ xStepValue: number;
15
+ xStepLabels: number[];
16
+ yStepValue: number;
17
+ yStepLabels: number[];
18
+ itemWidth: number;
19
+ valueHeight: number;
20
+ chartLeft: number;
21
+ chartBottom: number;
22
+ type: string;
23
+ }
24
+
25
+ export default function GridLines($props: GridLinesProps) {
26
+ @render {
27
+ <svg>
28
+ @if ($props.showHLines && $props.yStepLabels) {
29
+ @for (let [i, label] of $props.yStepLabels!.entries()) {
30
+ <line
31
+ x1={$props.chartLeft ?? 0}
32
+ y1={$props.chartBottom - i * $props.yStepValue * $props.valueHeight}
33
+ x2={$props.width ?? 0}
34
+ y2={$props.chartBottom - i * $props.yStepValue * $props.valueHeight}
35
+ />
36
+ }
37
+ }
38
+
39
+ @if ($props.showVLines) {
40
+ @if ($props.xStepLabels.length && $props.xStepValue) {
41
+ @for (let [i, label] of $props.xStepLabels.entries()) {
42
+ <line
43
+ x1={$props.chartLeft + i * $props.xStepValue * $props.itemWidth}
44
+ y1={$props.chartBottom}
45
+ x2={$props.chartLeft + i * $props.xStepValue * $props.itemWidth}
46
+ y2={0}
47
+ />
48
+ }
49
+ } else {
50
+ @for (let [i, label] of $props.labels.entries()) {
51
+ <line
52
+ x1={$props.chartLeft + i * $props.itemWidth + ($props.type === "bar" ? 0 : $props.itemWidth / 2)}
53
+ y1={$props.chartBottom}
54
+ x2={$props.chartLeft + i * $props.itemWidth + ($props.type === "bar" ? 0 : $props.itemWidth / 2)}
55
+ y2={0}
56
+ />
57
+ }
58
+ }
59
+ }
60
+ </svg>
61
+ }
62
+ }
@@ -0,0 +1,196 @@
1
+ import type { ClassValue, StyleValue } from "@torpor/view";
2
+
3
+ import XAxis from "./XAxis.torp";
4
+ import YAxis from "./YAxis.torp";
5
+ import GridLines from "./GridLines.torp";
6
+ import {
7
+ calculateMaxValue,
8
+ calculateStepValue,
9
+ calculateStepLabels,
10
+ calculateItemWidth,
11
+ calculateValueHeight,
12
+ calculateChartBottom,
13
+ calculateChartLeft
14
+ } from "../utils/chartUtils";
15
+ import { chartColors } from "../utils/chartColors";
16
+
17
+ import type DataSeries from "./DataSeries";
18
+
19
+ interface LineChartProps {
20
+ /** An ID for the root element */
21
+ id?: string;
22
+ /** Classes for the root element */
23
+ class?: ClassValue,
24
+ /** Styles for the root element */
25
+ style?: StyleValue,
26
+ series: DataSeries[],
27
+ height?: number,
28
+ width?: number,
29
+ xLabel?: string,
30
+ yLabel?: string,
31
+ maxValue?: number,
32
+ stepCount?: number,
33
+ stepValue?: number,
34
+ showPoints?: boolean,
35
+ hideXAxis?: boolean,
36
+ hideYAxis?: boolean,
37
+ showHLines?: boolean,
38
+ showVLines?: boolean,
39
+ }
40
+
41
+ export default function LineChart($props: LineChartProps) {
42
+ const defaultHeight = 200;
43
+ const defaultWidth = 400;
44
+
45
+ let container: HTMLElement;
46
+ let measurer: SVGTextElement;
47
+
48
+ let $state = $watch({
49
+ textWidth: 0,
50
+ textHeight: 0,
51
+ get labels() {
52
+ return $props.series.map((s) => s.name);
53
+ },
54
+ get calculatedHeight() {
55
+ return $props.height || defaultHeight;
56
+ },
57
+ get calculatedWidth() {
58
+ return $props.width || container?.clientWidth || defaultWidth;
59
+ },
60
+ get maxValue() {
61
+ return $props.maxValue || calculateMaxValue($props.series);
62
+ },
63
+ get calculatedStepValue() {
64
+ return $props.stepValue || calculateStepValue($state.maxValue, $props.stepCount);
65
+ },
66
+ get stepLabels() {
67
+ return calculateStepLabels($props.stepCount || 2, $state.calculatedStepValue);
68
+ },
69
+ get itemWidth() {
70
+ return calculateItemWidth($state.calculatedWidth, $state.chartLeft, $state.labels);
71
+ },
72
+ get valueHeight() {
73
+ return calculateValueHeight($state.chartBottom, $state.textHeight, $state.calculatedStepValue, $props.stepCount || 2);
74
+ },
75
+ get chartBottom() {
76
+ return calculateChartBottom($props.xLabel ?? "", $state.calculatedHeight, $state.textHeight);
77
+ },
78
+ get chartLeft() {
79
+ return calculateChartLeft($state.stepLabels, $props.yLabel ?? "", $state.textHeight, $state.textWidth);
80
+ },
81
+ get pointSeries() {
82
+ return buildPointSeries(
83
+ $props.series,
84
+ $state.chartLeft,
85
+ $state.chartBottom,
86
+ $state.itemWidth,
87
+ $state.valueHeight
88
+ );
89
+ }
90
+ });
91
+
92
+ $mount(() => {
93
+ // HACK: Wait until the measurer is really available
94
+ setTimeout(() => {
95
+ const bbox = measurer.getBBox();
96
+ $state.textWidth = bbox.width;
97
+ $state.textHeight = bbox.height * 1.5;
98
+ measurer.remove();
99
+ }, 1);
100
+ });
101
+
102
+ function buildPointSeries(
103
+ theSeries: DataSeries[],
104
+ theChartLeft: number,
105
+ theChartBottom: number,
106
+ theItemWidth: number,
107
+ theValueHeight: number
108
+ ): { x: number; y: number }[][] {
109
+ return theSeries.map((series) => {
110
+ return series.data.map((value, i) => {
111
+ const x = +(theChartLeft + i * theItemWidth + theItemWidth / 2).toFixed(2);
112
+ const y = +(theChartBottom - value * theValueHeight).toFixed(2);
113
+ return { x, y };
114
+ });
115
+ });
116
+ }
117
+
118
+ @render {
119
+ <div
120
+ {$props?.id}
121
+ class={["torp-line-chart", $props?.class]}
122
+ {$props?.style}
123
+ &ref={container}
124
+ >
125
+ <svg version="1.1" width={$state.calculatedWidth} height={$state.calculatedHeight}>
126
+ <g>
127
+ <text &ref={measurer} x="-999" y="-999">8</text>
128
+
129
+ @if ($state.textWidth && $state.textHeight) {
130
+ <XAxis
131
+ showAxis={!$props.hideXAxis}
132
+ height={$state.calculatedHeight}
133
+ width={$state.calculatedWidth}
134
+ {$state.itemWidth}
135
+ {$state.textHeight}
136
+ xLabel={$props.xLabel ?? ""}
137
+ {$state.labels}
138
+ stepLabels={[]}
139
+ stepValue={0}
140
+ {$state.chartLeft}
141
+ {$state.chartBottom}
142
+ />
143
+ <YAxis
144
+ showAxis={!$props.hideYAxis}
145
+ {$state.valueHeight}
146
+ {$state.textHeight}
147
+ yLabel={$props.yLabel ?? ""}
148
+ {$state.stepLabels}
149
+ stepValue={$state.calculatedStepValue}
150
+ {$state.chartLeft}
151
+ {$state.chartBottom}
152
+ />
153
+ <GridLines
154
+ {$props.showHLines}
155
+ {$props.showVLines}
156
+ width={$state.calculatedWidth}
157
+ {$state.labels}
158
+ xStepValue={0}
159
+ xStepLabels={[]}
160
+ yStepValue={$state.calculatedStepValue}
161
+ yStepLabels={[]}
162
+ {$state.stepLabels}
163
+ {$state.itemWidth}
164
+ {$state.valueHeight}
165
+ {$state.chartLeft}
166
+ {$state.chartBottom}
167
+ type="line"
168
+ />
169
+ @for (let [i, series] of $props.series.entries()) {
170
+ <polyline
171
+ points={$state.pointSeries[i].map((p) => `${p.x},${p.y}`).join(" ")}
172
+ stroke={series.color || chartColors[i % chartColors.length]}
173
+ stroke-width="2"
174
+ fill="none"
175
+ />
176
+ @if ($props.showPoints) {
177
+ @for (let [j, point] of $state.pointSeries[i].entries()) {
178
+ <circle
179
+ cx={point.x}
180
+ cy={point.y}
181
+ r="4"
182
+ stroke={series.color || chartColors[i % chartColors.length]}
183
+ stroke-width="2"
184
+ fill="white"
185
+ >
186
+ <title>{`${[series.name, $state.labels[j], series.data[j]].filter(Boolean).join("\n")}`}</title>
187
+ </circle>
188
+ }
189
+ }
190
+ }
191
+ }
192
+ </g>
193
+ </svg>
194
+ </div>
195
+ }
196
+ }
@@ -0,0 +1,145 @@
1
+ import type { ClassValue, StyleValue } from "@torpor/view";
2
+ import { chartColors } from "../utils/chartColors";
3
+
4
+ interface PieData {
5
+ name: string;
6
+ value: number;
7
+ color: string;
8
+ }
9
+
10
+ interface SegmentData {
11
+ name: string;
12
+ value: number;
13
+ color: string;
14
+ path: string;
15
+ }
16
+
17
+ interface PieChartProps {
18
+ /** An ID for the root element */
19
+ id?: string;
20
+ /** Classes for the root element */
21
+ class?: ClassValue,
22
+ /** Styles for the root element */
23
+ style?: StyleValue,
24
+ data: PieData[],
25
+ size?: number,
26
+ radius?: number,
27
+ cutout?: number,
28
+ }
29
+
30
+ export default function PieChart($props: PieChartProps) {
31
+ // Default values
32
+ $props.size ||= 200;
33
+ $props.radius ||= 50;
34
+
35
+ // NOTE: Take two pixels off the radius so that the stroke doesn't go outside the border
36
+ let $state = $watch({
37
+ get radius() {
38
+ return ($props.radius! / 100) * $props.size! - 2;
39
+ },
40
+ get cutout() {
41
+ return ($props.cutout! / 100) * $props.size! - 2;
42
+ },
43
+ get segments() {
44
+ return buildSegments($props.size!, $props.data, $props.radius!, $props.cutout!);
45
+ }
46
+ });
47
+
48
+ function buildSegments(
49
+ theSize: number,
50
+ theData: PieData[],
51
+ theRadiusSize: number,
52
+ theCutoutSize: number
53
+ ): SegmentData[] {
54
+ const segments = [];
55
+ const x = theSize / 2;
56
+ const y = theSize / 2;
57
+ const totalValue = theData.reduce((a, b) => a + b.value, 0);
58
+
59
+ // Start at the top of the chart, at 180 + 360 so that we don't have to
60
+ // take wrapping angles into account
61
+ let startAngle = 540;
62
+ for (let i = 0; i < theData.length; i++) {
63
+ // Subtract the angle to go clockwise
64
+ const endAngle = startAngle - (theData[i].value / totalValue) * 360;
65
+ const largeArc = startAngle - endAngle > 180 ? 1 : 0;
66
+ segments.push({
67
+ name: theData[i].name,
68
+ value: theData[i].value,
69
+ color: theData[i].color,
70
+ path: buildPath(x, y, theRadiusSize, theCutoutSize, startAngle, endAngle, largeArc)
71
+ });
72
+ startAngle = endAngle;
73
+ }
74
+ return segments;
75
+ }
76
+
77
+ function buildPath(
78
+ x: number,
79
+ y: number,
80
+ theRadiusSize: number,
81
+ theCutoutSize: number,
82
+ startAngle: number,
83
+ endAngle: number,
84
+ largeArc: number
85
+ ): string {
86
+ // HACK: Drawing an arc of 360 degrees doesn't work
87
+ if (startAngle - endAngle === 360) {
88
+ endAngle = 359.9999;
89
+ }
90
+
91
+ // Use some trig to find the points on the circumference at the start and end angles
92
+ const startx = x + theRadiusSize * Math.sin((Math.PI * startAngle) / 180);
93
+ const starty = x + theRadiusSize * Math.cos((Math.PI * startAngle) / 180);
94
+
95
+ const endx = x + theRadiusSize * Math.sin((Math.PI * endAngle) / 180);
96
+ const endy = y + theRadiusSize * Math.cos((Math.PI * endAngle) / 180);
97
+
98
+ if ($props.cutout) {
99
+ // Use some trig to find the points on the cutout at the start and end angles
100
+ const cutoutstartx = x + theCutoutSize * Math.sin((Math.PI * startAngle) / 180);
101
+ const cutoutstarty = x + theCutoutSize * Math.cos((Math.PI * startAngle) / 180);
102
+
103
+ const cutoutendx = x + theCutoutSize * Math.sin((Math.PI * endAngle) / 180);
104
+ const cutoutendy = y + theCutoutSize * Math.cos((Math.PI * endAngle) / 180);
105
+
106
+ // Build the path - move to the cutout start, line to the start angle on the circumference,
107
+ // arc to the end angle on the circumference, line to the cutout end, and then arc to the
108
+ // cutout start
109
+ const path = `
110
+ M${cutoutstartx},${cutoutstarty}
111
+ L${startx},${starty}
112
+ A${theRadiusSize},${theRadiusSize} 0 ${largeArc} 1 ${endx},${endy}
113
+ L${cutoutendx},${cutoutendy}
114
+ A${theCutoutSize},${theCutoutSize} 0 ${largeArc} 0 ${cutoutstartx},${cutoutstarty}`.trim();
115
+ return path;
116
+ } else {
117
+ // Build the path - move to the origin, line to the start angle on the circumference,
118
+ // arc to the end angle on the circumference, and then close back to the start
119
+ const path = `M${x},${y} L${startx},${starty} A${theRadiusSize},${theRadiusSize} 0 ${largeArc} 1 ${endx},${endy} Z`;
120
+ return path;
121
+ }
122
+ }
123
+
124
+ @render {
125
+ <div
126
+ {$props?.id}
127
+ class={["torp-pie-chart", $props?.class]}
128
+ {$props?.style}
129
+ >
130
+ <svg
131
+ version="1.1"
132
+ width={$props.size}
133
+ height={$props.size}
134
+ >
135
+ <g>
136
+ @for (let [i, segment] of $state.segments.entries()) {
137
+ <path d={segment.path} fill={segment.color || chartColors[i % chartColors.length]}>
138
+ <title>{`${segment.name}\n${segment.value}`}</title>
139
+ </path>
140
+ }
141
+ </g>
142
+ </svg>
143
+ </div>
144
+ }
145
+ }