Merge pull request #2 from KevinMidboe/refactor/graph-api-data

Refactor: Graph api data
This commit is contained in:
2023-05-30 17:39:01 +02:00
committed by GitHub
17 changed files with 295 additions and 263 deletions

View File

@@ -31,7 +31,7 @@
},
"type": "module",
"dependencies": {
"chart.js": "^3.8.0",
"chart.js": "^4.3.0",
"chartjs-plugin-zoom": "^1.2.1",
"d3": "^7.8.4",
"d3-selection": "^3.0.0",

View File

@@ -120,7 +120,7 @@
);
background-size: 150px 150px;
animation: move-it 12s linear infinite;
animation: move-it 8s linear infinite;
}
@keyframes move-it {

View File

@@ -7,9 +7,11 @@
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Title,
Legend
} from 'chart.js';
import { getRelativePosition } from 'chart.js/helpers';
import type { ChartDataset } from 'chart.js';
import type IChartFrame from '../interfaces/IChartFrame';
@@ -20,123 +22,112 @@
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Title,
Legend
);
export let name: string;
export let dataFrames: IChartFrame[];
export let beginAtZero: boolean = true;
export let hideTitle: boolean;
let chartCanvas: HTMLCanvasElement;
let chart: Chart;
let prevData: any = {};
interface IDataset {
labels: string[];
data?: ChartDataset<'line', number[]>;
}
onMount(() => renderChart());
afterUpdate(() => {
chart.destroy();
renderChart();
});
interface ITemperatureDataset extends IDataset {
inside: ChartDataset<'line', number[]>;
outside?: ChartDataset<'line', number[]>;
}
// Converts Date to format suitable for the current range displayed
function dateLabelsFormatedBasedOnResolution(dataFrames: IChartFrame[]): string[] {
const firstFrame = dataFrames[0];
const lastFrame = dataFrames[dataFrames.length - 1];
const deltaSeconds =
(new Date(lastFrame.key).getTime() - new Date(firstFrame.key).getTime()) / 1000;
let dateOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'numeric',
day: 'numeric'
};
interface IHumidityDataset extends IDataset {}
interface IPressureDataset extends IDataset {}
function pad(num) {
if (num < 10) {
return `0${num}`;
if (deltaSeconds < 3600) {
dateOptions = { hour: 'numeric', minute: 'numeric', second: 'numeric' };
} else if (deltaSeconds <= 86400) {
dateOptions = { hour: 'numeric', minute: 'numeric' };
} else if (deltaSeconds <= 2592000) {
dateOptions = {
day: 'numeric',
month: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: 'numeric'
};
}
return num;
const scaledDate = new Intl.DateTimeFormat('no-NB', dateOptions);
return dataFrames.map((frame) => scaledDate.format(frame.key));
}
function prettierDateString(date) {
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${pad(date.getYear() - 100)}`;
}
function computeTemperatureDataset(): ITemperatureDataset {
const labels: string[] = dataFrames.map(
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
);
const data: number[] = dataFrames.map((frame) => frame.value);
return {
labels,
inside: {
label: '℃ inside',
borderColor: '#10e783',
backgroundColor: '#c8f9df',
lineTension: 0.5,
borderWidth: 3,
data
}
};
}
function computeHumidityDataset(): IHumidityDataset {
const labels: string[] = dataFrames.map(
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
);
const data: number[] = dataFrames.map((frame) => frame.value);
return {
labels,
data: {
label: '% humidity',
borderColor: '#57d2fb',
backgroundColor: '#d4f2fe',
lineTension: 0.5,
borderWidth: 3,
data
}
};
}
function computePressureDataset(): IPressureDataset {
const labels: string[] = dataFrames.map(
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
);
const data: number[] = dataFrames.map((frame) => frame.value);
return {
labels,
data: {
// set dataset label & colors matching the name sent as prop
function setDataColorAndName(data: ChartDataset) {
if (name === 'Pressure') {
Object.assign(data, {
label: 'Bar of pressure',
borderColor: '#ef5878',
backgroundColor: '#fbd7de',
lineTension: 0.5,
borderWidth: 3,
data
}
};
backgroundColor: '#fbd7de'
});
} else if (name === 'Humidity') {
Object.assign(data, {
label: '% humidity',
borderColor: '#57d2fb',
backgroundColor: '#d4f2fe'
});
} else if (name === 'Temperature') {
Object.assign(data, {
label: '℃ inside',
borderColor: '#10e783',
backgroundColor: '#c8f9df'
});
}
}
function renderChart() {
const context: CanvasRenderingContext2D = chartCanvas.getContext('2d');
const context: CanvasRenderingContext2D | null = chartCanvas.getContext('2d');
if (!context) return
let dataset: IDataset | ITemperatureDataset | IHumidityDataset | IPressureDataset;
if (name === 'Temperature') dataset = computeTemperatureDataset();
else if (name === 'Humidity') dataset = computeHumidityDataset();
else if (name === 'Pressure') dataset = computePressureDataset();
// create labels and singular dataset (data)
const labels: string[] = dateLabelsFormatedBasedOnResolution(dataFrames);
const data: ChartDataset = {
data: dataFrames.map((frame) => frame.value),
borderWidth: 3,
};
// based on name, add label and color options to dataset
setDataColorAndName(data)
// create chart instance, most here is chart options
chart = new Chart(context, {
type: 'line',
data: {
labels: dataset.labels,
datasets: [dataset?.inside || dataset.data]
labels: labels,
datasets: [data]
},
options: {
elements: {
point: {
radius: 1
radius: 2
},
line: {
tension: 0.5
}
},
maintainAspectRatio: false,
plugins: {
title: {
display: true,
display: !hideTitle,
position: 'left',
position: 'top',
text: `${name} over time`,
font: {
size: 20
@@ -163,8 +154,21 @@
// },
mode: 'xy'
}
},
tooltip: {
titleFont: {
size: 14
},
bodyFont: {
size: 14
},
enabled: true
}
},
interaction: {
intersect: false,
mode: 'index'
},
scales: {
y: {
beginAtZero: false,
@@ -187,14 +191,6 @@
chart.update();
}
onMount(() => renderChart());
afterUpdate(() => {
console.log('after update run');
chart.destroy();
renderChart();
});
</script>
<canvas class="card" id="{name}" bind:this="{chartCanvas}" width="400" height="400"></canvas>
<canvas id="{name}" bind:this="{chartCanvas}" width="400" height="400"></canvas>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
export const title: string = "Temperature"
let temp = 23
</script>
<div>
<header>{ title }</header>
<div class="body">
<p>Inside temperature: {23}</p>
</div>
</div>
<style lang="scss" module="scoped">
.body {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -83,8 +83,8 @@ function buildQuery(field: String, from: Date, to: Date, interval: String) {
{
range: {
'@timestamp': {
gte: toDateString,
lte: fromDateString,
gte: fromDateString,
lte: toDateString,
format: 'strict_date_optional_time'
}
}
@@ -138,7 +138,7 @@ function calculateInterval(from, to, interval, size) {
if (interval !== 'auto') {
return interval;
}
const dateMathInterval = roundInterval((from - to) / size);
const dateMathInterval = roundInterval((to - from) / size);
// const dateMathIntervalMs = toMS(dateMathInterval);
// const minMs = toMS(min);
// if (dateMathIntervalMs !== undefined && minMs !== undefined && dateMathIntervalMs < minMs) {
@@ -148,6 +148,7 @@ function calculateInterval(from, to, interval, size) {
}
function parseTempResponse(data: IESTelemetry): IChartFrame[] {
console.log('got temp response:', data);
return data?.aggregations?.data?.buckets.map((bucket) => {
return {
value: bucket?.maxValue?.value,
@@ -161,12 +162,7 @@ function parseLatestResponse(data: IESTelemetry) {
return data?.hits?.hits[0]?._source;
}
export function fetchTemperature(
from: Date,
to: Date,
size: number = 50,
fetch: Function
): Promise<IChartFrame[]> {
export function fetchTemperature(from: Date, to: Date, size: number = 50): Promise<IChartFrame[]> {
const fromMS = from.getTime();
const toMS = to.getTime();
const interval = calculateInterval(fromMS, toMS, 'auto', size);
@@ -181,18 +177,14 @@ export function fetchTemperature(
},
body: JSON.stringify(esSearchQuery)
};
console.log('temp options:', options);
return fetch(TELEMETRY_ENDPOINT, options)
.then((resp) => resp.json())
.then(parseTempResponse);
}
export function fetchHumidity(
from: Date,
to: Date,
size: number = 50,
fetch: Function
): Promise<IChartFrame[]> {
export function fetchHumidity(from: Date, to: Date, size: number = 50): Promise<IChartFrame[]> {
const fromMS = from.getTime();
const toMS = to.getTime();
const interval = calculateInterval(fromMS, toMS, 'auto', size);
@@ -213,12 +205,7 @@ export function fetchHumidity(
.then(parseTempResponse);
}
export function fetchPressure(
from: Date,
to: Date,
size: number = 50,
fetch: Function
): Promise<IChartFrame[]> {
export function fetchPressure(from: Date, to: Date, size: number = 50): Promise<IChartFrame[]> {
const fromMS = from.getTime();
const toMS = to.getTime();
const interval = calculateInterval(fromMS, toMS, 'auto', size);

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import HeaderComponent from '$lib/components/Header.svelte';
import HeaderComponent from '../lib/components/Header.svelte';
// import Darkmode from '$lib/components/Darkmode.svelte'
import '../styles/global.css';
</script>

View File

@@ -1,7 +1,5 @@
import { getLatestInsideReadings, getLatestOutsideReadings } from '$lib/graphQueryGenerator';
import type { PageServerLoad } from './$types';
let DEFAULT_MINUTES = 14400;
const host = 'http://brewpi.schleppe:5000';
const sensorsUrl = `${host}/api/sensors`;
const relaysUrl = `${host}/api/relays`;
@@ -24,8 +22,6 @@ async function getRelays() {
export const load: PageServerLoad = async () => {
const [sensors, relays] = await Promise.all([getSensors(), getRelays()]);
console.log('got sensors and relays');
console.log(sensors, relays);
const inside = sensors.find((sensor) => sensor.location === 'inside');
const outside = sensors.find((sensor) => sensor.location === 'outside');

View File

@@ -1,13 +1,12 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte'
import Display from '$lib/components/display.svelte'
import VerticalSensorDisplay from '$lib/components/VerticalSensorDisplay.svelte'
import Livestream from '$lib/components/Livestream.svelte'
import BrewProgress from '$lib/components/BrewProgress.svelte';
import PageHeader from '../lib/components/PageHeader.svelte';
import VerticalSensorDisplay from '../lib/components/VerticalSensorDisplay.svelte';
// import Livestream from '$lib/components/Livestream.svelte'
import BrewProgress from '../lib/components/BrewProgress.svelte';
import type { PageData } from './$types';
import RelayControls from '../lib/components/RelayControls.svelte';
export let data: PageData
export let data: PageData;
const { inside, outside, relays } = data;
</script>
@@ -16,9 +15,9 @@
<div class="vertical-grid">
<BrewProgress />
<VerticalSensorDisplay {inside} {outside} />
<VerticalSensorDisplay inside="{inside}" outside="{outside}" />
<RelayControls {relays} />
<RelayControls relays="{relays}" />
<!-- <Livestream /> -->
</div>

View File

@@ -0,0 +1,39 @@
import { json, RequestEvent } from '@sveltejs/kit';
import {
fetchTemperature,
fetchHumidity,
fetchPressure
} from '../../../../lib/server/graphQueryGenerator';
import type { RequestHandler } from './$types';
const UNITS = ['temperature', 'humidity', 'pressure'];
const UNITS_STRING = UNITS.join(', ');
export const POST = (async (event: RequestEvent) => {
const { request, params } = event;
const { unit } = params;
if (!unit || UNITS.indexOf(unit) == -1) {
return json({
success: false,
message: `Unit ${unit} not found. Choose from: ${UNITS_STRING}`
});
}
const bodyData = await request.json();
let data;
let { from, to } = bodyData;
const { size } = bodyData;
from = new Date(from);
to = new Date(to);
if (unit === 'temperature') {
data = await fetchTemperature(from, to, size);
} else if (unit === 'humidity') {
data = await fetchHumidity(from, to, size);
} else if (unit === 'pressure') {
data = await fetchPressure(from, to, size);
}
return json({ success: true, data });
}) satisfies RequestHandler;

View File

@@ -1,4 +1,4 @@
import brews from '../../brews.json'
import brews from '../../brews.json';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {

View File

@@ -5,7 +5,7 @@
const path = (date: string) => '/brews/' + String(date);
const dateFormat = { year: 'numeric', month: 'short', day: 'numeric' };
const dateString = (date) => new Date(date * 1000).toLocaleDateString('no-NB', dateFormat);
const dateString = (date: number) => new Date(date * 1000).toLocaleDateString('no-NB', dateFormat);
</script>

View File

@@ -1,8 +1,25 @@
import { error } from '@sveltejs/kit';
import brews from '../../../brews.json';
import { fetchHumidity, fetchTemperature } from '../../../lib/server/graphQueryGenerator';
import type { PageLoad } from './$types';
export const load = (({ params }) => {
async function fetchGraphData(brew) {
const start = new Date(brew.date * 1000 - 86400000);
const end = new Date(brew.date * 1000 + 4838400000);
const size = 200;
const [temperature, humidity] = await Promise.all([
fetchTemperature(start, end, size),
fetchHumidity(start, end, size)
]);
return {
temperature,
humidity
};
}
export const load = (async ({ params }) => {
const { date } = params;
const brew = brews.find((b) => b?.date === date);
@@ -10,5 +27,7 @@ export const load = (({ params }) => {
throw error(404, 'Brew not found');
}
return { brew };
const graphData = await fetchGraphData(brew);
return { brew, graphData };
}) satisfies PageLoad;

View File

@@ -1,27 +1,19 @@
<script lang="ts">
import Graph from '../../../lib/components/Graph.svelte';
import { fetchTemperature, fetchHumidity } from '../../../lib/graphQueryGenerator';
import IChartFrame from '../../../lib/interfaces/IChartFrame';
let height: number;
// let brew = {
// recipe: 'https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ',
// bryggselv: 'https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter',
// untapped: 'https://untappd.com/b/kinn-bryggeri-kveldsbris/695024'
// }
export let data;
let brew = data.brew;
let temperatureData: IChartFrame[];
let humidityData: IChartFrame[];
let temperatureData: IChartFrame[] = data.graphData.temperature;
let humidityData: IChartFrame[] = data.graphData.humidity;
const from: Date = new Date();
const to = new Date(1684872000000);
const size = 40;
fetchTemperature(from, to, size, fetch).then((resp) => (temperatureData = resp));
fetchHumidity(from, to, size, fetch).then((resp) => (humidityData = resp));
const dateFormat = { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' };
const dateFormat: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric'
};
const dateString = new Date(Number(brew.date * 1000)).toLocaleDateString('no-NB', dateFormat);
const wizards = brew.by.join(', ');
@@ -75,19 +67,21 @@
ble Tuborg Bryggeri en del av Carlsberg.
</p>
{#if temperatureData}
<div class="graph">
<h3>Temperature during fermentation</h3>
<Graph dataFrames="{temperatureData}" name="Temperature" />
</div>
{/if}
<div class="graph-container">
{#if temperatureData}
<div class="graph">
<h3>Temperature during fermentation</h3>
<Graph dataFrames="{temperatureData}" name="Temperature" hideTitle="{true}" />
</div>
{/if}
{#if humidityData}
<div class="graph">
<h3>Humidity during carbonation</h3>
<Graph dataFrames="{humidityData}" name="Humidity" />
</div>
{/if}
{#if humidityData}
<div class="graph">
<h3>Humidity during carbonation</h3>
<Graph dataFrames="{humidityData}" name="Humidity" hideTitle="{true}" />
</div>
{/if}
</div>
<h3>Smak</h3>
<p>
@@ -222,6 +216,12 @@
font-weight: 300;
}
.graph-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.graph {
width: 100%;
max-height: 50vh;

View File

@@ -1,13 +1,21 @@
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
import {
fetchTemperature,
fetchHumidity,
fetchPressure
} from '../../lib/server/graphQueryGenerator';
import type { PageServerLoad } from './$types';
import type IChartFrame from '$lib/interfaces/IChartFrame';
import type IChartFrame from '../../lib/interfaces/IChartFrame';
let DEFAULT_MINUTES = 10080;
const DEFAULT_MINUTES = 10080;
export const load: PageServerLoad = async ({ fetch }) => {
const temperatureData: IChartFrame[] = await getTemp(DEFAULT_MINUTES, fetch);
const humidityData: IChartFrame[] = await getHumidity(DEFAULT_MINUTES, fetch);
const pressureData: IChartFrame[] = await getPressure(DEFAULT_MINUTES, fetch);
const to = new Date();
const from = new Date(to.getTime() - DEFAULT_MINUTES * 60 * 1000);
const size = 40;
const temperatureData: IChartFrame[] = await fetchTemperature(from, to, size);
const humidityData: IChartFrame[] = await fetchHumidity(from, to, size);
const pressureData: IChartFrame[] = await fetchPressure(from, to, size);
return {
temperatureData,
@@ -16,23 +24,3 @@ export const load: PageServerLoad = async ({ fetch }) => {
DEFAULT_MINUTES
};
};
function getSensor(func: Function, minutes: number, fetch: Function) {
const from: Date = new Date();
const to = new Date(from.getTime() - minutes * 60 * 1000);
const size = 40;
return func(from, to, size, fetch);
}
function getTemp(minutes: number, fetch: Function): IChartFrame[] {
return getSensor(fetchTemperature, minutes, fetch);
}
function getHumidity(minutes: number, fetch: Function): IChartFrame[] {
return getSensor(fetchHumidity, minutes, fetch);
}
function getPressure(minutes: number, fetch: Function): IChartFrame[] {
return getSensor(fetchPressure, minutes, fetch);
}

View File

@@ -1,80 +1,89 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
import Graph from '$lib/components/Graph.svelte';
import type IChartFrame from '$lib/interfaces/IChartFrame';
import Graph from '../../lib/components/Graph.svelte';
import type IChartFrame from '../../lib/interfaces/IChartFrame';
import type { PageData } from './$types';
export let data: PageData
export let data: PageData;
let temperatureData: IChartFrame[] = data?.temperatureData;
let humidityData: IChartFrame[] = data?.temperatureData;
let pressureData: IChartFrame[] = data?.temperatureData;
let DEFAULT_MINUTES: number = data?.DEFAULT_MINUTES
let humidityData: IChartFrame[] = data?.humidityData;
let pressureData: IChartFrame[] = data?.pressureData;
let DEFAULT_MINUTES: number = data?.DEFAULT_MINUTES;
let minutes: number = DEFAULT_MINUTES;
const buttonMinutes = [{
value: 15,
name: 'Last 15 minutes'
}, {
value: 60,
name: 'Last hour'
}, {
value: 1440,
name: 'Last day'
}, {
value: 10080,
name: 'Last week'
}, {
value: 43200,
name: 'Last month'
}, {
value: 129600,
name: 'Last 3 months'
}, {
value: 259200,
name: 'Last 6 months'
}, {
value: 518400,
name: 'Last year',
}]
async function fetchData(unit: string, from: Date, to: Date, size: number) {
const options = {
method: 'POST',
body: JSON.stringify({ from, to, size })
};
return fetch(`/api/graph/${unit}`, options).then((resp) => resp.json());
}
const buttonMinutes = [
{ value: 15, name: 'Last 15 minutes' },
{ value: 60, name: 'Last hour' },
{ value: 360, name: 'Last 6 hours' },
{ value: 1440, name: 'Last day' },
{ value: 10080, name: 'Last week' },
{ value: 43200, name: 'Last month' },
{ value: 129600, name: 'Last 3 months' },
{ value: 259200, name: 'Last 6 months' },
{ value: 518400, name: 'Last year' }
];
function reload(mins: number) {
minutes = mins
const from: Date = new Date();
const to = new Date(from.getTime() - minutes * 60 * 1000);
minutes = mins;
const to: Date = new Date();
const from = new Date(to.getTime() - minutes * 60 * 1000);
const size = 40;
fetchTemperature(from, to, size, window.fetch).then((resp) => (temperatureData = resp));
fetchHumidity(from, to, size, window.fetch).then((resp) => (humidityData = resp));
fetchPressure(from, to, size, window.fetch).then((resp) => (pressureData = resp));
fetchData('temperature', from, to, size).then((resp) => (temperatureData = resp?.data));
fetchData('humidity', from, to, size).then((resp) => (humidityData = resp?.data));
fetchData('pressure', from, to, size).then((resp) => (pressureData = resp?.data));
}
function scrollSelectedButtonIntoView() {
const container = document.getElementsByClassName('button-wrapper')[0];
const selected = document.getElementsByClassName('selected')[0] as HTMLElement;
const containerWidth = container.getBoundingClientRect().width;
const selectedWidth = selected.getBoundingClientRect().width;
// if the container is in-view, return
if (containerWidth > selected.offsetLeft) {
return;
}
container.scrollLeft = selected.offsetLeft - selectedWidth / 2;
}
onMount(scrollSelectedButtonIntoView);
</script>
<!-- <h1>Server: {emoji.emoji}</h1> -->
<input type="number" bind:value={minutes} on:input={() => reload(minutes)} />
<div class="button-wrapper">
{#each buttonMinutes as button}
<button on:click={() => reload(button.value)} class="{button.value === minutes ? 'selected' : ''}">{ button.name }</button>
<button
on:click="{() => reload(button.value)}"
class="{button.value === minutes ? 'selected' : ''}">{button.name}</button
>
{/each}
</div>
<section class="graphs">
{#if temperatureData}
<div class="graphWrapper">
<Graph dataFrames={temperatureData} name="Temperature" />
<div class="card">
<Graph dataFrames="{temperatureData}" name="Temperature" />
</div>
{/if}
{#if humidityData}
<div class="graphWrapper">
<Graph dataFrames={humidityData} name="Humidity" beginAtZero={false} />
<div class="card">
<Graph dataFrames="{humidityData}" name="Humidity" />
</div>
{/if}
{#if pressureData}
<div class="graphWrapper">
<Graph dataFrames={pressureData} name="Pressure" beginAtZero={false} />
<div class="card">
<Graph dataFrames="{pressureData}" name="Pressure" />
</div>
{/if}
</section>
@@ -89,17 +98,21 @@
width: 100%;
@include mobile {
grid-template-columns: 1fr;
display: block;
> *:not(*:first-child) {
margin-top: 1rem;
}
}
.graphWrapper {
max-width: 100vw;
.card {
padding: 1rem;
}
}
.button-wrapper {
display: flex;
width: min-content;
margin: 1rem 0;
overflow-y: scroll;
}
button {

View File

@@ -96,6 +96,14 @@ li {
font-style: normal;
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Bold.eot?#iefix') format('embedded-opentype'),
url('/fonts/Roboto-Bold.woff') format('woff'), url('/fonts/Roboto-Bold.ttf') format('truetype');
font-weight: 700;
font-style: bold;
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Light.ttf') format('truetype');

View File

@@ -164,6 +164,11 @@
"@jridgewell/resolve-uri" "3.1.0"
"@jridgewell/sourcemap-codec" "1.4.14"
"@kurkle/color@^0.3.0":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f"
integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@@ -513,10 +518,12 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chart.js@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.8.0.tgz#c6c14c457b9dc3ce7f1514a59e9b262afd6f1a94"
integrity sha512-cr8xhrXjLIXVLOBZPkBZVF6NDeiVIrPLHcMhnON7UufudL+CNeRrD+wpYanswlm8NpudMdrt3CHoLMQMxJhHRg==
chart.js@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.3.0.tgz#ac363030ab3fec572850d2d872956f32a46326a1"
integrity sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==
dependencies:
"@kurkle/color" "^0.3.0"
chartjs-plugin-zoom@^1.2.1:
version "1.2.1"