@coreusa/final-barline 0.1.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/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/Barline.svelte +624 -0
- package/dist/Barline.svelte.d.ts +36 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/types/DataSeriesInterface.d.ts +4 -0
- package/dist/types/DataSeriesInterface.js +1 -0
- package/eslint.config.js +39 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coreus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Final Barline
|
|
2
|
+
|
|
3
|
+
A high-performance SVG Chart library for Svelte 5. Supports bar and line type of graphs with option to export the graph and a wide support for customization. Responsive by default.
|
|
4
|
+
|
|
5
|
+
<img width="630" height="496" alt="image" src="https://github.com/user-attachments/assets/ffe674c0-9fbc-4799-87ec-9bf275faa361" />
|
|
6
|
+
|
|
7
|
+
<img width="630" height="496" alt="image" src="https://github.com/user-attachments/assets/2d67a102-5b6b-42f7-a49d-f5fcac708da7" />
|
|
8
|
+
|
|
9
|
+
## General use
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
# install Final Barline
|
|
13
|
+
#pnpm
|
|
14
|
+
pnpm add @coreusa/final-barline
|
|
15
|
+
|
|
16
|
+
# or npm
|
|
17
|
+
npm install @coreusa/final-barline
|
|
18
|
+
|
|
19
|
+
# or yarn
|
|
20
|
+
yarn add @coreusa/final-barline
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
# import the component
|
|
25
|
+
import Barline from '@coreusa/final-barline';
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
# Configure and setup the component
|
|
30
|
+
|
|
31
|
+
<Barline
|
|
32
|
+
data={data}
|
|
33
|
+
type="line"
|
|
34
|
+
height={400}
|
|
35
|
+
width={600}
|
|
36
|
+
title="Chart title"
|
|
37
|
+
lineWidth={2}
|
|
38
|
+
yMaxValuePadding={1}
|
|
39
|
+
/>
|
|
40
|
+
|
|
41
|
+
```
|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Basic SVG Chart component that supports lines and bar type of charts.
|
|
3
|
+
Charts can be customized through various properties like padding, line width and much more.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import type { DataSeriesInterface } from './types/DataSeriesInterface.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
data = [
|
|
10
|
+
{
|
|
11
|
+
label: '',
|
|
12
|
+
values: [0]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
title = '',
|
|
16
|
+
type = 'line',
|
|
17
|
+
width = 400,
|
|
18
|
+
height = 200,
|
|
19
|
+
xMin = undefined,
|
|
20
|
+
xMax = undefined,
|
|
21
|
+
yMin = undefined,
|
|
22
|
+
yMax = undefined,
|
|
23
|
+
yMaxValuePadding = 0,
|
|
24
|
+
timeFormatting = false,
|
|
25
|
+
showLegend = true,
|
|
26
|
+
lineWidth = 3,
|
|
27
|
+
palette = ['#4BEF8F', '#d62728', '#FFC83B', '#17becf', '#dbdb8d', '#D4484B', '#9edae5'],
|
|
28
|
+
xValueSuffix = '',
|
|
29
|
+
yValueSuffix = '',
|
|
30
|
+
xValueCulling = 10,
|
|
31
|
+
xValuePrecision = 1,
|
|
32
|
+
yValuePrecision = 1,
|
|
33
|
+
xValues = [],
|
|
34
|
+
paddingSides = {
|
|
35
|
+
top: 20,
|
|
36
|
+
bottom: 0,
|
|
37
|
+
right: 10,
|
|
38
|
+
left: 20
|
|
39
|
+
},
|
|
40
|
+
showHorizontalGridLines = true,
|
|
41
|
+
showVerticalGridLines = true,
|
|
42
|
+
yValueCulling = 6,
|
|
43
|
+
responsive = true
|
|
44
|
+
}: {
|
|
45
|
+
data: DataSeriesInterface[];
|
|
46
|
+
type: 'line' | 'bar';
|
|
47
|
+
title?: string;
|
|
48
|
+
width?: number;
|
|
49
|
+
height?: number;
|
|
50
|
+
xMin?: number;
|
|
51
|
+
xMax?: number;
|
|
52
|
+
yMin?: number;
|
|
53
|
+
yMax?: number;
|
|
54
|
+
yMaxValuePadding?: number; // How much extra space to add on top of the computed max y value
|
|
55
|
+
timeFormatting?: boolean;
|
|
56
|
+
showLegend?: boolean;
|
|
57
|
+
lineWidth?: number;
|
|
58
|
+
palette?: string[];
|
|
59
|
+
xValueSuffix?: string;
|
|
60
|
+
yValueSuffix?: string;
|
|
61
|
+
// Number of x axis values to display (max)
|
|
62
|
+
xValueCulling?: number;
|
|
63
|
+
// Number of y axis values to display (max)
|
|
64
|
+
yValueCulling?: number;
|
|
65
|
+
// Number precision for x values
|
|
66
|
+
xValuePrecision?: number;
|
|
67
|
+
// Number precision for y values
|
|
68
|
+
yValuePrecision?: number;
|
|
69
|
+
// Optionally provide the x values corresponding to each data point, otherwise assume that x values are 0, 1, 2, etc.)
|
|
70
|
+
xValues?: number[];
|
|
71
|
+
paddingSides?: {
|
|
72
|
+
top: number;
|
|
73
|
+
bottom: number;
|
|
74
|
+
left: number;
|
|
75
|
+
right: number;
|
|
76
|
+
};
|
|
77
|
+
showHorizontalGridLines?: boolean;
|
|
78
|
+
showVerticalGridLines?: boolean;
|
|
79
|
+
// Whether the chart resizes to parent container responsively or not (default true)
|
|
80
|
+
responsive?: boolean;
|
|
81
|
+
} = $props();
|
|
82
|
+
|
|
83
|
+
let chartContainerWidth = $state(0);
|
|
84
|
+
let showGraphGrid = $state(true);
|
|
85
|
+
let svgChartElement: SVGSVGElement;
|
|
86
|
+
// Start position of the X/Y axis, to the right of the Y axis
|
|
87
|
+
const chartSafetyMarginX = 50;
|
|
88
|
+
const chartSafetyMarginBottom = 20;
|
|
89
|
+
|
|
90
|
+
let chartWidth = $derived.by(() => {
|
|
91
|
+
if (responsive) {
|
|
92
|
+
return chartContainerWidth;
|
|
93
|
+
} else {
|
|
94
|
+
return width;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The inner width of the chart (where X starts (min, to the left) and ends (max, to the right)
|
|
99
|
+
let innerWidth = $derived.by(() => {
|
|
100
|
+
return chartWidth - (paddingSides.left + paddingSides.right + chartSafetyMarginX);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The inner height of the chart (where Y starts (min, bottom) and ends (max, top). Additionally adds a safety margin at the bottom so x values can be shown
|
|
104
|
+
const innerHeight = $derived.by(
|
|
105
|
+
() => height - (paddingSides.top + paddingSides.bottom) - chartSafetyMarginBottom
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
let isEnoughDataForPresentation = $derived.by(() => {
|
|
109
|
+
// Ensure there is at least one data series and that series has at least 1 data point
|
|
110
|
+
return data?.length > 0 && data[0]?.values?.length > 1;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Scales an x value based on the width of the chart and the min/max x values observed in the data set
|
|
115
|
+
* @param x
|
|
116
|
+
*/
|
|
117
|
+
const xScale = (x: number) => {
|
|
118
|
+
// TODO: Add padding to computedXMax if need for padding on the right side
|
|
119
|
+
// NOTE: Added padding to the right side to ensure scale factor has enough room
|
|
120
|
+
const ratio = (x - computedXMin) / (computedXMax + 1 - computedXMin);
|
|
121
|
+
const res =
|
|
122
|
+
chartSafetyMarginX / 2 + paddingSides.left + paddingSides.right + ratio * chartWidth;
|
|
123
|
+
// Clamp to the drawable area
|
|
124
|
+
return Math.max(paddingSides.left + paddingSides.right, Math.min(chartWidth, res));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const yScale = (y: number) => {
|
|
128
|
+
const ratio = (y - computedYMin) / (computedYMax - computedYMin);
|
|
129
|
+
const res = innerHeight + (paddingSides.top + paddingSides.bottom) - ratio * innerHeight;
|
|
130
|
+
return isNaN(res) ? 0 : res;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// All numeric values across every series – used for autoscaling y‑axis
|
|
134
|
+
const allValues = $derived.by(() => {
|
|
135
|
+
if (data?.length && data[0].values?.length > 2) {
|
|
136
|
+
return data.flatMap((d) => {
|
|
137
|
+
return d.values;
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
return [0];
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// X‑axis is based on the number of points (assumes all series share the same length)
|
|
145
|
+
const pointCount = $derived.by(() => {
|
|
146
|
+
// Test if there are x values present (custom x ticks provided)
|
|
147
|
+
if (xValues?.length > 0) {
|
|
148
|
+
return xValues.length;
|
|
149
|
+
} else if (data?.length && data[0]?.values?.length > 0) {
|
|
150
|
+
// Sample only the first data series. Point count must be the same across all data series
|
|
151
|
+
return data[0]?.values?.length;
|
|
152
|
+
} else {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// What data index is currently under the mouse cursor
|
|
158
|
+
let dataHoverIndex = $state(-1);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Variables related to tooltip position and mouse hovering the chart
|
|
162
|
+
*/
|
|
163
|
+
let mouseHoverX = $derived(dataHoverIndex >= 0 ? xScale(dataHoverIndex) : 0);
|
|
164
|
+
|
|
165
|
+
let tooltipX = $derived(mouseHoverX + 10);
|
|
166
|
+
let tooltipY = $state(0);
|
|
167
|
+
|
|
168
|
+
let isHoveringChart = $state(false);
|
|
169
|
+
|
|
170
|
+
// Handle all values if they are undefined
|
|
171
|
+
const computedXMin = $derived(xMin ?? 0);
|
|
172
|
+
const computedXMax = $derived(xMax ?? pointCount - 1);
|
|
173
|
+
const computedYMin = $derived(yMin ?? Math.min(...allValues));
|
|
174
|
+
const computedYMax = $derived(yMax ?? Math.max(...allValues) + yMaxValuePadding);
|
|
175
|
+
|
|
176
|
+
/*
|
|
177
|
+
/**
|
|
178
|
+
* Returns an array of SVG path strings, one per series
|
|
179
|
+
*/
|
|
180
|
+
const graphLines = $derived.by(() => {
|
|
181
|
+
// Iterate each data series and create paths
|
|
182
|
+
const paths = data.map((series) => {
|
|
183
|
+
const points = series.values;
|
|
184
|
+
if (points?.length < 2) {
|
|
185
|
+
// Empty line if there's not enough data
|
|
186
|
+
return 'M 0 0';
|
|
187
|
+
}
|
|
188
|
+
// Create a line for each data series and concatinate to a SVG path string
|
|
189
|
+
return points
|
|
190
|
+
.map((value, index) => {
|
|
191
|
+
const x = xScale(index);
|
|
192
|
+
const y = yScale(value);
|
|
193
|
+
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
|
|
194
|
+
})
|
|
195
|
+
.join(' ');
|
|
196
|
+
});
|
|
197
|
+
return paths;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Mouse handler for when the user hovers the chart (to show tooltip and vertical index/position line)
|
|
202
|
+
* @param event
|
|
203
|
+
*/
|
|
204
|
+
const onMouseMove = (event: MouseEvent) => {
|
|
205
|
+
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
|
206
|
+
const mouseX = event.clientX - rect.left - chartSafetyMarginX + paddingSides.left;
|
|
207
|
+
// Convert from the dimensions of the image to a corresponding data index in the chart
|
|
208
|
+
const relative = mouseX / chartWidth;
|
|
209
|
+
const index = Math.round(computedXMin + relative * (computedXMax - computedXMin));
|
|
210
|
+
tooltipY = event.clientY - rect.top + 10; // 10px below the mouse
|
|
211
|
+
dataHoverIndex = Math.max(0, Math.min(pointCount - 1, index));
|
|
212
|
+
isHoveringChart = true;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onMouseLeave = () => {
|
|
216
|
+
isHoveringChart = false;
|
|
217
|
+
dataHoverIndex = -1;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bar chart groups. Do not calculate them if the chart type is not bar
|
|
222
|
+
*/
|
|
223
|
+
const graphBars = $derived.by(() => {
|
|
224
|
+
if (type !== 'bar' || !isEnoughDataForPresentation) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const barWidth = innerWidth / pointCount;
|
|
229
|
+
|
|
230
|
+
const groups = Array.from({ length: pointCount }, (_, dataSeriesIndex) => {
|
|
231
|
+
const xBaseLine = xScale(dataSeriesIndex);
|
|
232
|
+
return data.map((series, seriesIndex) => {
|
|
233
|
+
const v = series.values[dataSeriesIndex];
|
|
234
|
+
const x = xBaseLine + seriesIndex * barWidth - barWidth / 2;
|
|
235
|
+
const y = Math.abs(yScale(v));
|
|
236
|
+
const h = innerHeight + (paddingSides.top + paddingSides.bottom) - y;
|
|
237
|
+
return {
|
|
238
|
+
x,
|
|
239
|
+
y,
|
|
240
|
+
w: barWidth,
|
|
241
|
+
h
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
return groups;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Creates values for X, taking into account any x culling settings
|
|
250
|
+
*/
|
|
251
|
+
const xTicks = $derived.by(() => {
|
|
252
|
+
const range = computedXMax - computedXMin;
|
|
253
|
+
const step = Math.max(1, Math.floor(range / xValueCulling));
|
|
254
|
+
|
|
255
|
+
const indexes = Array.from({ length: xValueCulling + 1 }, (_, i) => computedXMin + i * step);
|
|
256
|
+
|
|
257
|
+
// Use a set to ensure unique values
|
|
258
|
+
const unique = Array.from(new Set(indexes));
|
|
259
|
+
|
|
260
|
+
return unique.map((index) => {
|
|
261
|
+
// TODO: Using base index as fallback will cause issues when adding a xValuePadding and using custom xValues
|
|
262
|
+
const value = xValues?.[index] !== undefined ? xValues[index] : index;
|
|
263
|
+
return {
|
|
264
|
+
value:
|
|
265
|
+
timeFormatting && value === 0
|
|
266
|
+
? 'Now'
|
|
267
|
+
: `${value.toFixed(xValuePrecision)}${xValueSuffix}`,
|
|
268
|
+
position: xScale(index)
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Creates values for Y, taking into account any y culling settings
|
|
275
|
+
*/
|
|
276
|
+
const yTicks = $derived.by(() => {
|
|
277
|
+
return Array.from({ length: yValueCulling }, (_, i) => {
|
|
278
|
+
const value = computedYMin + (i / (yValueCulling - 1)) * (computedYMax - computedYMin);
|
|
279
|
+
return {
|
|
280
|
+
value,
|
|
281
|
+
y: yScale(value)
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Map legend data series labels and corresponding color in the palette
|
|
288
|
+
*/
|
|
289
|
+
const legend = $derived.by(() => {
|
|
290
|
+
return data.map((series, index) => {
|
|
291
|
+
return {
|
|
292
|
+
label: series.label,
|
|
293
|
+
color: palette[index % palette.length]
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const legendWidth = $derived.by(() => {
|
|
299
|
+
if (legend.length > 0) {
|
|
300
|
+
// Determine how long each legend can be based on the number of data series and the chart width (minus padding on each side)
|
|
301
|
+
return Math.min(
|
|
302
|
+
100,
|
|
303
|
+
(innerWidth - 2 * paddingSides.left + paddingSides.right) / legend.length
|
|
304
|
+
);
|
|
305
|
+
} else {
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Determine where to start placing the legend (far end of chart)
|
|
312
|
+
*/
|
|
313
|
+
const legendStartX = $derived.by(() => {
|
|
314
|
+
if (legend.length > 0) {
|
|
315
|
+
// Center the legend in the remaining space after taking into account the legend width and padding on each side
|
|
316
|
+
return paddingSides.left + paddingSides.right + (innerWidth - legendWidth * legend.length);
|
|
317
|
+
} else {
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
let chartFilename = $derived.by(() => {
|
|
323
|
+
const timeNow = Date.now();
|
|
324
|
+
let fileNamePrefix = 'chart-export-';
|
|
325
|
+
if (title?.length) {
|
|
326
|
+
fileNamePrefix = title.toLowerCase().replace(/\s+/g, '-');
|
|
327
|
+
}
|
|
328
|
+
return `${fileNamePrefix}-${timeNow}.svg`;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const exportSvg = (svgElement: SVGSVGElement) => {
|
|
332
|
+
const clone = svgElement.cloneNode(true) as SVGSVGElement;
|
|
333
|
+
const serializer = new XMLSerializer();
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* TODO: Consider adding a toggle whether or not to default coloring of exported
|
|
337
|
+
* chart to avoid removing custom user profiling / styles
|
|
338
|
+
*/
|
|
339
|
+
const darkColour = '#222';
|
|
340
|
+
// Change all text to dark so the chart can be read properly
|
|
341
|
+
clone.querySelectorAll('text').forEach((txt) => {
|
|
342
|
+
// If the element already has a fill, overwrite it; otherwise just set it
|
|
343
|
+
txt.setAttribute('fill', darkColour);
|
|
344
|
+
txt.setAttribute('font-family', 'Open Sans');
|
|
345
|
+
// Some charts use stroke for text – set that as well just in case
|
|
346
|
+
txt.setAttribute('stroke', darkColour);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const svgString = serializer.serializeToString(clone);
|
|
350
|
+
// Add XML declaration + optional DOCTYPE for better compatibility
|
|
351
|
+
const svgBlob = new Blob(
|
|
352
|
+
[
|
|
353
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n',
|
|
354
|
+
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"\n',
|
|
355
|
+
' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n',
|
|
356
|
+
svgString
|
|
357
|
+
],
|
|
358
|
+
{ type: 'image/svg+xml;charset=utf-8' }
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const url = URL.createObjectURL(svgBlob);
|
|
362
|
+
const link = document.createElement('a');
|
|
363
|
+
link.href = url;
|
|
364
|
+
link.download = chartFilename;
|
|
365
|
+
link.click();
|
|
366
|
+
URL.revokeObjectURL(url);
|
|
367
|
+
};
|
|
368
|
+
const horizontalPadding = $derived.by(() => paddingSides.left + paddingSides.right);
|
|
369
|
+
const verticalPadding = $derived.by(() => paddingSides.top + paddingSides.bottom);
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<div bind:clientWidth={chartContainerWidth} class="barline-chart">
|
|
373
|
+
<svg
|
|
374
|
+
bind:this={svgChartElement}
|
|
375
|
+
width={chartWidth}
|
|
376
|
+
{height}
|
|
377
|
+
viewBox={`0 0 ${chartWidth} ${height}`}
|
|
378
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
379
|
+
>
|
|
380
|
+
<!-- Title -->
|
|
381
|
+
<text
|
|
382
|
+
x={chartWidth / 2}
|
|
383
|
+
y={paddingSides.top / 2}
|
|
384
|
+
text-anchor="middle"
|
|
385
|
+
font-size="14"
|
|
386
|
+
class="barline-chart-title fw-bold"
|
|
387
|
+
fill="#222">{title}</text
|
|
388
|
+
>
|
|
389
|
+
|
|
390
|
+
<!-- Only plot graph if there is at least 1 data series with 1 data point -->
|
|
391
|
+
{#if isEnoughDataForPresentation}
|
|
392
|
+
<!-- Axis labels and grid lines -->
|
|
393
|
+
<!-- X-axis -->
|
|
394
|
+
{#each xTicks as { value, position }, index (`x-axis-label-${index}`)}
|
|
395
|
+
{#if showGraphGrid}
|
|
396
|
+
<!-- LINES - Vertical -->
|
|
397
|
+
<line
|
|
398
|
+
x1={position}
|
|
399
|
+
x2={position}
|
|
400
|
+
y1={showVerticalGridLines ? verticalPadding : height - verticalPadding + 5}
|
|
401
|
+
y2={height - paddingSides.bottom - chartSafetyMarginBottom}
|
|
402
|
+
stroke="#e0e0e0"
|
|
403
|
+
stroke-width="1"
|
|
404
|
+
/>
|
|
405
|
+
{/if}
|
|
406
|
+
<!-- LABELS - X axis -->
|
|
407
|
+
<text
|
|
408
|
+
x={position}
|
|
409
|
+
y={innerHeight + verticalPadding + 15}
|
|
410
|
+
text-anchor="middle"
|
|
411
|
+
font-size="12"
|
|
412
|
+
transform={`rotate(0, ${position + 5}, ${height})`}
|
|
413
|
+
fill="#222"
|
|
414
|
+
>
|
|
415
|
+
{value}
|
|
416
|
+
</text>
|
|
417
|
+
{/each}
|
|
418
|
+
|
|
419
|
+
<!-- Y‑axis -->
|
|
420
|
+
{#each yTicks as { value, y }, index (`y-axis-label-${index}`)}
|
|
421
|
+
{#if showGraphGrid}
|
|
422
|
+
<!-- LINES - Horizontal -->
|
|
423
|
+
<line
|
|
424
|
+
x1={showHorizontalGridLines ? chartSafetyMarginX / 2 + 20 : verticalPadding}
|
|
425
|
+
x2={showHorizontalGridLines ? chartWidth + chartSafetyMarginX : horizontalPadding - 5}
|
|
426
|
+
y1={y}
|
|
427
|
+
y2={y}
|
|
428
|
+
stroke="#ccc"
|
|
429
|
+
stroke-width="1"
|
|
430
|
+
/>
|
|
431
|
+
{/if}
|
|
432
|
+
<!-- LABELS - Y axis. NOTE: Add margin for last (top Y value) in case no top padding is in place -->
|
|
433
|
+
<text
|
|
434
|
+
x={chartSafetyMarginX - 10}
|
|
435
|
+
y={index === yTicks.length - 1 ? y + 10 : y + 5}
|
|
436
|
+
text-anchor="end"
|
|
437
|
+
font-size="12"
|
|
438
|
+
font-family="open sans"
|
|
439
|
+
fill="#222"
|
|
440
|
+
>
|
|
441
|
+
{value.toFixed(1)}
|
|
442
|
+
{yValueSuffix}
|
|
443
|
+
</text>
|
|
444
|
+
{/each}
|
|
445
|
+
|
|
446
|
+
{#if type === 'line'}
|
|
447
|
+
{#each graphLines as path, lineIndex (`line-${lineIndex}`)}
|
|
448
|
+
<path
|
|
449
|
+
d={path}
|
|
450
|
+
fill="none"
|
|
451
|
+
stroke={palette[lineIndex % palette.length]}
|
|
452
|
+
stroke-width={lineWidth}
|
|
453
|
+
stroke-linejoin="round"
|
|
454
|
+
/>
|
|
455
|
+
{/each}
|
|
456
|
+
{:else if type === 'bar'}
|
|
457
|
+
{#each graphBars as group, groupIndex (`bar-group-${groupIndex}`)}}
|
|
458
|
+
{#each group as { x, y, w, h }, barIndex (`bar-group-${barIndex}-bar-${barIndex}`)}
|
|
459
|
+
<rect {x} {y} width={w} height={h} fill={palette[barIndex % palette.length]} />
|
|
460
|
+
{/each}
|
|
461
|
+
{/each}
|
|
462
|
+
{/if}
|
|
463
|
+
{:else}
|
|
464
|
+
<!-- Not enough data, show message -->
|
|
465
|
+
Please provide at least 1 data series with at least 1 data point in them.
|
|
466
|
+
{/if}
|
|
467
|
+
|
|
468
|
+
<!-- Add data series legend (if enabled) -->
|
|
469
|
+
{#if showLegend}
|
|
470
|
+
<!-- Background area behind legend for better eligibility -->
|
|
471
|
+
<rect
|
|
472
|
+
width={legendWidth * legend.length}
|
|
473
|
+
height="40"
|
|
474
|
+
x={legendStartX - (paddingSides.left + paddingSides.right) / 2}
|
|
475
|
+
y={paddingSides.top + 8}
|
|
476
|
+
fill="#222"
|
|
477
|
+
opacity="0.9"
|
|
478
|
+
/>
|
|
479
|
+
{#each legend as { label, color }, index (`data-series-legend-${index}`)}
|
|
480
|
+
<g transform={`translate(${legendStartX + legendWidth * index}, ${paddingSides.top + 20})`}>
|
|
481
|
+
<!-- Colored square matching the color of the corresponding data series -->
|
|
482
|
+
<rect width="14" height="14" fill={color} />
|
|
483
|
+
<!-- Name of the data series -->
|
|
484
|
+
<text x="20" y="13" font-size="16" class="fw-bold" fill="#fff">
|
|
485
|
+
{label}
|
|
486
|
+
</text>
|
|
487
|
+
</g>
|
|
488
|
+
{/each}
|
|
489
|
+
{/if}
|
|
490
|
+
|
|
491
|
+
<!-- Vertical and horizontal line upon mouse hover -->
|
|
492
|
+
{#if mouseHoverX > 0}
|
|
493
|
+
<!-- Vertical hover line -->
|
|
494
|
+
<line
|
|
495
|
+
x1={mouseHoverX}
|
|
496
|
+
x2={mouseHoverX}
|
|
497
|
+
y1={verticalPadding}
|
|
498
|
+
y2={height - verticalPadding}
|
|
499
|
+
stroke="#2B9C6A"
|
|
500
|
+
stroke-width="1"
|
|
501
|
+
stroke-dasharray="4 4"
|
|
502
|
+
/>
|
|
503
|
+
<!-- Horizontal hover line -->
|
|
504
|
+
<line
|
|
505
|
+
x1={0}
|
|
506
|
+
x2={chartContainerWidth}
|
|
507
|
+
y1={tooltipY - 10}
|
|
508
|
+
y2={tooltipY - 10}
|
|
509
|
+
stroke="#2B9C6A"
|
|
510
|
+
stroke-width="1"
|
|
511
|
+
stroke-dasharray="4 4"
|
|
512
|
+
/>
|
|
513
|
+
{/if}
|
|
514
|
+
|
|
515
|
+
<!-- Transparent overlay to capture mouse events -->
|
|
516
|
+
<rect
|
|
517
|
+
x={0}
|
|
518
|
+
y={0}
|
|
519
|
+
width={chartContainerWidth}
|
|
520
|
+
{height}
|
|
521
|
+
fill="transparent"
|
|
522
|
+
role="presentation"
|
|
523
|
+
onmousemove={onMouseMove}
|
|
524
|
+
onmouseleave={onMouseLeave}
|
|
525
|
+
/>
|
|
526
|
+
</svg>
|
|
527
|
+
<!-- Tooltip for the graph -->
|
|
528
|
+
{#if isHoveringChart && dataHoverIndex >= 0}
|
|
529
|
+
<div
|
|
530
|
+
class="barline-tooltip"
|
|
531
|
+
style="
|
|
532
|
+
left: {tooltipX}px;
|
|
533
|
+
top: {tooltipY}px;
|
|
534
|
+
pointer-events: none;
|
|
535
|
+
"
|
|
536
|
+
>
|
|
537
|
+
<h6 class="barline-tooltip-header">
|
|
538
|
+
<!-- If custom X Values have been provided (and there's no x max), use the hover index and lookup the value in that array. Otherwise use the data series index -->
|
|
539
|
+
{xValues?.length > 0 && xMax === undefined
|
|
540
|
+
? xValues[dataHoverIndex].toFixed(xValuePrecision)
|
|
541
|
+
: dataHoverIndex.toFixed(xValuePrecision)}
|
|
542
|
+
{xValueSuffix}
|
|
543
|
+
</h6>
|
|
544
|
+
<div class="barline-tooltip-content">
|
|
545
|
+
<dd>
|
|
546
|
+
{#each data as dataSeries, seriesIndex (`data-series-tooltip-${seriesIndex}`)}
|
|
547
|
+
<dl>
|
|
548
|
+
<span style="color:{palette[seriesIndex % palette.length]}">■ </span>
|
|
549
|
+
<strong>
|
|
550
|
+
{dataSeries.label}
|
|
551
|
+
{dataSeries.values[dataHoverIndex].toFixed(yValuePrecision)}
|
|
552
|
+
{yValueSuffix}
|
|
553
|
+
</strong>
|
|
554
|
+
</dl>
|
|
555
|
+
{/each}
|
|
556
|
+
</dd>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
{/if}
|
|
560
|
+
<div class="d-flex justify-content-center">
|
|
561
|
+
<button
|
|
562
|
+
class="barline-export-chart-button"
|
|
563
|
+
onclick={() => {
|
|
564
|
+
exportSvg(svgChartElement);
|
|
565
|
+
}}
|
|
566
|
+
>
|
|
567
|
+
Export graph
|
|
568
|
+
</button>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<style>
|
|
573
|
+
.barline-chart {
|
|
574
|
+
font-family: 'Open Sans', 'Lato', 'Helvetica', 'Ubuntu', sans-serif;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.barline-export-chart-button {
|
|
578
|
+
background-color: #222;
|
|
579
|
+
color: #fff;
|
|
580
|
+
border: none;
|
|
581
|
+
padding: 8px 16px;
|
|
582
|
+
border-radius: 4px;
|
|
583
|
+
font-size: 14px;
|
|
584
|
+
cursor: pointer;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.barline-export-chart-button:hover {
|
|
588
|
+
background-color: #3f3f3f;
|
|
589
|
+
color: #ffac11;
|
|
590
|
+
transition: all 0.25s ease-in-out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
.barline-tooltip {
|
|
594
|
+
position: absolute;
|
|
595
|
+
background-color: #fff;
|
|
596
|
+
color: #222;
|
|
597
|
+
text-wrap: nowrap;
|
|
598
|
+
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.4);
|
|
599
|
+
border-radius: 4px;
|
|
600
|
+
z-index: 500;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.barline-chart-title {
|
|
604
|
+
font-weight: bold;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.barline-tooltip-header {
|
|
608
|
+
/*bg-dark-blue-horizontal-gradient text-white p-2*/
|
|
609
|
+
background-color: #222;
|
|
610
|
+
color: #fff;
|
|
611
|
+
font-size: 1.1rem;
|
|
612
|
+
padding: 5px 10px;
|
|
613
|
+
margin: 0;
|
|
614
|
+
text-align: center;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.barline-tooltip-content {
|
|
618
|
+
padding: 10px;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.barline-tooltip-content dd {
|
|
622
|
+
margin: 0;
|
|
623
|
+
}
|
|
624
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DataSeriesInterface } from './types/DataSeriesInterface.ts';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
data: DataSeriesInterface[];
|
|
4
|
+
type: 'line' | 'bar';
|
|
5
|
+
title?: string;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
xMin?: number;
|
|
9
|
+
xMax?: number;
|
|
10
|
+
yMin?: number;
|
|
11
|
+
yMax?: number;
|
|
12
|
+
yMaxValuePadding?: number;
|
|
13
|
+
timeFormatting?: boolean;
|
|
14
|
+
showLegend?: boolean;
|
|
15
|
+
lineWidth?: number;
|
|
16
|
+
palette?: string[];
|
|
17
|
+
xValueSuffix?: string;
|
|
18
|
+
yValueSuffix?: string;
|
|
19
|
+
xValueCulling?: number;
|
|
20
|
+
yValueCulling?: number;
|
|
21
|
+
xValuePrecision?: number;
|
|
22
|
+
yValuePrecision?: number;
|
|
23
|
+
xValues?: number[];
|
|
24
|
+
paddingSides?: {
|
|
25
|
+
top: number;
|
|
26
|
+
bottom: number;
|
|
27
|
+
left: number;
|
|
28
|
+
right: number;
|
|
29
|
+
};
|
|
30
|
+
showHorizontalGridLines?: boolean;
|
|
31
|
+
showVerticalGridLines?: boolean;
|
|
32
|
+
responsive?: boolean;
|
|
33
|
+
};
|
|
34
|
+
declare const Barline: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
35
|
+
type Barline = ReturnType<typeof Barline>;
|
|
36
|
+
export default Barline;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import prettier from 'eslint-config-prettier';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { includeIgnoreFile } from '@eslint/compat';
|
|
4
|
+
import js from '@eslint/js';
|
|
5
|
+
import svelte from 'eslint-plugin-svelte';
|
|
6
|
+
import { defineConfig } from 'eslint/config';
|
|
7
|
+
import globals from 'globals';
|
|
8
|
+
import ts from 'typescript-eslint';
|
|
9
|
+
import svelteConfig from './svelte.config.js';
|
|
10
|
+
|
|
11
|
+
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
|
12
|
+
|
|
13
|
+
export default defineConfig(
|
|
14
|
+
includeIgnoreFile(gitignorePath),
|
|
15
|
+
js.configs.recommended,
|
|
16
|
+
ts.configs.recommended,
|
|
17
|
+
svelte.configs.recommended,
|
|
18
|
+
prettier,
|
|
19
|
+
svelte.configs.prettier,
|
|
20
|
+
{
|
|
21
|
+
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
|
22
|
+
rules: {
|
|
23
|
+
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
|
24
|
+
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
|
25
|
+
'no-undef': 'off'
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
|
30
|
+
languageOptions: {
|
|
31
|
+
parserOptions: {
|
|
32
|
+
projectService: true,
|
|
33
|
+
extraFileExtensions: ['.svelte'],
|
|
34
|
+
parser: ts.parser,
|
|
35
|
+
svelteConfig
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coreusa/final-barline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Final Barline - The lightweight, high performance SVG Chart Library",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"SVG Chart",
|
|
7
|
+
"High performance chart",
|
|
8
|
+
"Svelte 5 Chart"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/Coreusa/final-barline#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Coreusa/final-barline/issues"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Coreusa/final-barline.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Coreus",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"svelte": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "eslint.config.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"!dist/**/*.test.*",
|
|
32
|
+
"!dist/**/*.spec.*"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"dev": "vite dev",
|
|
36
|
+
"build": "vite build && npm run prepack",
|
|
37
|
+
"preview": "vite preview",
|
|
38
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
39
|
+
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
40
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
41
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
42
|
+
"lint": "prettier --check . && eslint .",
|
|
43
|
+
"format": "prettier --write .",
|
|
44
|
+
"test:unit": "vitest",
|
|
45
|
+
"test": "npm run test:unit -- --run"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/compat": "^2.0.2",
|
|
49
|
+
"@eslint/js": "^9.39.2",
|
|
50
|
+
"@sveltejs/adapter-auto": "^7.0.0",
|
|
51
|
+
"@sveltejs/kit": "^2.50.2",
|
|
52
|
+
"@sveltejs/package": "^2.5.7",
|
|
53
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
54
|
+
"@types/node": "^24",
|
|
55
|
+
"@vitest/browser-playwright": "^4.1.0",
|
|
56
|
+
"eslint": "^9.39.2",
|
|
57
|
+
"eslint-config-prettier": "^10.1.8",
|
|
58
|
+
"eslint-plugin-svelte": "^3.14.0",
|
|
59
|
+
"globals": "^17.3.0",
|
|
60
|
+
"playwright": "^1.58.2",
|
|
61
|
+
"prettier": "^3.8.1",
|
|
62
|
+
"prettier-plugin-svelte": "^3.4.1",
|
|
63
|
+
"publint": "^0.3.17",
|
|
64
|
+
"svelte": "^5.51.0",
|
|
65
|
+
"svelte-check": "^4.4.2",
|
|
66
|
+
"typescript": "^5.9.3",
|
|
67
|
+
"typescript-eslint": "^8.54.0",
|
|
68
|
+
"vite": "^7.3.1",
|
|
69
|
+
"vitest": "^4.1.0",
|
|
70
|
+
"vitest-browser-svelte": "^2.0.2"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"svelte": "^5.0.0"
|
|
74
|
+
},
|
|
75
|
+
"sideEffects": [
|
|
76
|
+
"**/*.css"
|
|
77
|
+
],
|
|
78
|
+
"svelte": "./dist/index.js"
|
|
79
|
+
}
|