From 36cfe49b20f9ddd7f726b5f5dacfe2aa99ba56a2 Mon Sep 17 00:00:00 2001 From: nym21 Date: Sun, 7 Jun 2026 16:46:53 +0200 Subject: [PATCH] website: redesign part 23 --- website_next/index.html | 1 + website_next/learn/charts/area/index.js | 62 +++++++++++++++ website_next/learn/charts/area/style.css | 9 +++ website_next/learn/charts/bar/index.js | 6 +- website_next/learn/charts/dots/index.js | 17 ++++- website_next/learn/charts/index.js | 84 ++++++++++++++++++--- website_next/learn/charts/line/index.js | 17 ++++- website_next/learn/charts/line/series.js | 12 +-- website_next/learn/charts/order.js | 59 +++++++++++++++ website_next/learn/charts/plot.js | 44 ++++++++--- website_next/learn/charts/renderer.js | 4 + website_next/learn/charts/scale.js | 18 +++++ website_next/learn/charts/stacked/index.js | 6 +- website_next/learn/charts/stacked/series.js | 52 ++++++------- website_next/learn/charts/views.js | 10 +-- 15 files changed, 328 insertions(+), 73 deletions(-) create mode 100644 website_next/learn/charts/area/index.js create mode 100644 website_next/learn/charts/area/style.css create mode 100644 website_next/learn/charts/order.js diff --git a/website_next/index.html b/website_next/index.html index bdade82db..7cb6131f3 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -103,6 +103,7 @@ + diff --git a/website_next/learn/charts/area/index.js b/website_next/learn/charts/area/index.js new file mode 100644 index 000000000..7721abda3 --- /dev/null +++ b/website_next/learn/charts/area/index.js @@ -0,0 +1,62 @@ +import { createAreaPathData, createLinePathData } from "../path.js"; +import { appendSeriesPath } from "../series-path.js"; +import { createOrderedIndexes } from "../order.js"; +import { createLineSeries } from "../line/series.js"; + +/** + * @param {number} height + * @param {{ date: Date, value: number, x: number, y: number }[]} points + */ +function createAreaPoints(height, points) { + return points.map((point) => ({ + ...point, + y0: height, + y1: point.y, + })); +} + +/** + * @param {SVGGElement} group + * @param {LoadedSeries[]} loadedSeries + * @param {number} height + * @param {SeriesHighlight} highlight + * @param {import("../scale.js").ChartScale} scale + * @param {import("../order.js").ChartOrder} order + */ +export function renderAreaPlot( + group, + loadedSeries, + height, + highlight, + scale, + order, +) { + const plottedSeries = createLineSeries(loadedSeries, height, scale); + const indexes = createOrderedIndexes(plottedSeries.length, order); + + for (const index of indexes) { + const { color, points } = plottedSeries[index]; + appendSeriesPath({ + group, + highlight, + index, + chart: "area", + color, + d: createAreaPathData(createAreaPoints(height, points)), + }); + + appendSeriesPath({ + group, + highlight, + index, + chart: "line", + color, + d: createLinePathData(points), + }); + } + + return plottedSeries; +} + +/** @typedef {import("../highlight.js").SeriesHighlight} SeriesHighlight */ +/** @typedef {import("../index.js").LoadedSeries} LoadedSeries */ diff --git a/website_next/learn/charts/area/style.css b/website_next/learn/charts/area/style.css new file mode 100644 index 000000000..da877ff94 --- /dev/null +++ b/website_next/learn/charts/area/style.css @@ -0,0 +1,9 @@ +main.learn { + figure[data-chart="series"] { + path[data-chart="area"] { + fill: var(--color, var(--orange)); + fill-opacity: 0.5; + stroke: none; + } + } +} diff --git a/website_next/learn/charts/bar/index.js b/website_next/learn/charts/bar/index.js index a6f60d1ec..1a1c6cd71 100644 --- a/website_next/learn/charts/bar/index.js +++ b/website_next/learn/charts/bar/index.js @@ -35,21 +35,21 @@ function createBarPathData(points, width) { * @param {LoadedSeries[]} loadedSeries * @param {number} height * @param {SeriesHighlight} highlight - * @param {{ reversed: boolean }} options * @param {import("../scale.js").ChartScale} scale + * @param {import("../order.js").ChartOrder} order */ export function renderBarPlot( group, loadedSeries, height, highlight, - options, scale, + order, ) { const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries( loadedSeries, height, - options.reversed, + order, scale, ); diff --git a/website_next/learn/charts/dots/index.js b/website_next/learn/charts/dots/index.js index 1ce93f633..9fe6a78c1 100644 --- a/website_next/learn/charts/dots/index.js +++ b/website_next/learn/charts/dots/index.js @@ -1,5 +1,6 @@ import { formatCoordinate } from "../path.js"; import { appendSeriesPath } from "../series-path.js"; +import { createOrderedIndexes } from "../order.js"; import { createLineSeries } from "../line/series.js"; const radius = 1; @@ -22,11 +23,21 @@ function createDotsPathData(points) { * @param {number} height * @param {SeriesHighlight} highlight * @param {import("../scale.js").ChartScale} scale + * @param {import("../order.js").ChartOrder} order */ -export function renderDotsPlot(group, loadedSeries, height, highlight, scale) { +export function renderDotsPlot( + group, + loadedSeries, + height, + highlight, + scale, + order, +) { const plottedSeries = createLineSeries(loadedSeries, height, scale); + const indexes = createOrderedIndexes(plottedSeries.length, order); - plottedSeries.forEach(({ color, points }, index) => { + for (const index of indexes) { + const { color, points } = plottedSeries[index]; appendSeriesPath({ group, highlight, @@ -35,7 +46,7 @@ export function renderDotsPlot(group, loadedSeries, height, highlight, scale) { color, d: createDotsPathData(points), }); - }); + } return plottedSeries; } diff --git a/website_next/learn/charts/index.js b/website_next/learn/charts/index.js index d165b5bbf..232ad7c77 100644 --- a/website_next/learn/charts/index.js +++ b/website_next/learn/charts/index.js @@ -1,6 +1,11 @@ import { createFullscreenButton } from "./fullscreen.js"; import { onChartVisibility } from "./intersection.js"; import { createLegend } from "./legend/index.js"; +import { + createOrderControl, + getDefaultOrder, + saveOrder, +} from "./order.js"; import { createChartRenderer } from "./renderer.js"; import { createScaleControl, @@ -20,6 +25,36 @@ import { } from "./views.js"; import { FALLBACK_VIEWBOX_HEIGHT, VIEWBOX_WIDTH } from "./viewbox.js"; +/** + * @template {string} T + * @param {Object} args + * @param {T} args.currentValue + * @param {(currentValue: T, onChange: (value: T) => void) => HTMLFieldSetElement} args.createControl + * @param {(chartKey: string, value: T) => void} args.save + * @param {string} args.chartKey + * @param {HTMLElement} args.figure + * @param {string} args.dataKey + * @param {(value: T) => void} args.setValue + * @param {() => void} args.render + */ +function createChartSettingControl({ + currentValue, + createControl, + save, + chartKey, + figure, + dataKey, + setValue, + render, +}) { + return createControl(currentValue, (value) => { + setValue(value); + save(chartKey, value); + figure.dataset[dataKey] = value; + render(); + }); +} + /** @param {Chart} chart */ export function createChart(chart) { const figure = document.createElement("figure"); @@ -33,6 +68,7 @@ export function createChart(chart) { let currentTimeframe = getDefaultTimeframe(chartKey); let currentView = getDefaultView(chartKey, chart.defaultType); let currentScale = getDefaultScale(chartKey, chart.defaultScale); + let currentOrder = getDefaultOrder(chartKey); const { legend, menu, items, readout } = createLegend(chart); figure.dataset.chart = "series"; @@ -40,6 +76,7 @@ export function createChart(chart) { figure.dataset.timeframe = currentTimeframe; figure.dataset.view = currentView; figure.dataset.scale = currentScale; + figure.dataset.order = currentOrder; svg.setAttribute( "viewBox", `0 0 ${VIEWBOX_WIDTH} ${FALLBACK_VIEWBOX_HEIGHT}`, @@ -59,19 +96,44 @@ export function createChart(chart) { chart, getView: () => currentView, getScale: () => currentScale, + getOrder: () => currentOrder, getTimeframe: () => currentTimeframe, }); - const viewControl = createViewControl(currentView, (view) => { - currentView = view; - saveView(chartKey, view); - figure.dataset.view = view; - renderer.renderCurrent(); + const viewControl = createChartSettingControl({ + currentValue: currentView, + createControl: createViewControl, + save: saveView, + chartKey, + figure, + dataKey: "view", + setValue: (view) => { + currentView = view; + }, + render: renderer.renderCurrent, }); - const scaleControl = createScaleControl(currentScale, (scale) => { - currentScale = scale; - saveScale(chartKey, scale); - figure.dataset.scale = scale; - renderer.renderCurrent(); + const scaleControl = createChartSettingControl({ + currentValue: currentScale, + createControl: createScaleControl, + save: saveScale, + chartKey, + figure, + dataKey: "scale", + setValue: (scale) => { + currentScale = scale; + }, + render: renderer.renderCurrent, + }); + const orderControl = createChartSettingControl({ + currentValue: currentOrder, + createControl: createOrderControl, + save: saveOrder, + chartKey, + figure, + dataKey: "order", + setValue: (order) => { + currentOrder = order; + }, + render: renderer.renderCurrent, }); const timeframeControl = createTimeframeControl( currentTimeframe, @@ -82,7 +144,7 @@ export function createChart(chart) { void renderer.loadCurrent(); }, ); - chartControls.append(viewControl, scaleControl); + chartControls.append(viewControl, scaleControl, orderControl); timeControls.append(timeframeControl, createFullscreenButton(figure)); controls.append(chartControls, timeControls); plot.append(svg, status); diff --git a/website_next/learn/charts/line/index.js b/website_next/learn/charts/line/index.js index 4b0f9b198..cb8a440ff 100644 --- a/website_next/learn/charts/line/index.js +++ b/website_next/learn/charts/line/index.js @@ -1,5 +1,6 @@ import { createLinePathData } from "../path.js"; import { appendSeriesPath } from "../series-path.js"; +import { createOrderedIndexes } from "../order.js"; import { createLineSeries } from "./series.js"; /** @@ -8,11 +9,21 @@ import { createLineSeries } from "./series.js"; * @param {number} height * @param {SeriesHighlight} highlight * @param {import("../scale.js").ChartScale} scale + * @param {import("../order.js").ChartOrder} order */ -export function renderLinePlot(group, loadedSeries, height, highlight, scale) { +export function renderLinePlot( + group, + loadedSeries, + height, + highlight, + scale, + order, +) { const plottedSeries = createLineSeries(loadedSeries, height, scale); + const indexes = createOrderedIndexes(plottedSeries.length, order); - plottedSeries.forEach(({ color, points }, index) => { + for (const index of indexes) { + const { color, points } = plottedSeries[index]; appendSeriesPath({ group, highlight, @@ -21,7 +32,7 @@ export function renderLinePlot(group, loadedSeries, height, highlight, scale) { color, d: createLinePathData(points), }); - }); + } return plottedSeries; } diff --git a/website_next/learn/charts/line/series.js b/website_next/learn/charts/line/series.js index 493490a00..059d53028 100644 --- a/website_next/learn/charts/line/series.js +++ b/website_next/learn/charts/line/series.js @@ -1,21 +1,17 @@ import { VIEWBOX_WIDTH } from "../viewbox.js"; -import { scaleY } from "../scale.js"; +import { createBounds, includeBoundValue, scaleY } from "../scale.js"; /** @param {LoadedSeries[]} series */ function createValueBounds(series) { - let min = Infinity; - let max = -Infinity; - let minPositive = Infinity; + const bounds = createBounds(); for (const { entries } of series) { for (const { value } of entries) { - min = Math.min(min, value); - max = Math.max(max, value); - if (value > 0) minPositive = Math.min(minPositive, value); + includeBoundValue(bounds, value); } } - return { min, max, minPositive }; + return bounds; } /** diff --git a/website_next/learn/charts/order.js b/website_next/learn/charts/order.js new file mode 100644 index 000000000..13af19886 --- /dev/null +++ b/website_next/learn/charts/order.js @@ -0,0 +1,59 @@ +import { createChartSetting } from "./setting.js"; + +const orders = /** @type {const} */ ([ + { value: "ascending", label: "Asc" }, + { value: "descending", label: "Dsc" }, +]); +const defaultOrder = "ascending"; +const setting = createChartSetting({ + storageKey: "order", + legend: "Order", + options: orders, + defaultValue: defaultOrder, +}); + +/** @param {string} chartKey */ +export function getDefaultOrder(chartKey) { + return setting.get(chartKey); +} + +/** + * @param {string} chartKey + * @param {ChartOrder} order + */ +export function saveOrder(chartKey, order) { + setting.save(chartKey, order); +} + +/** + * @param {ChartOrder} currentOrder + * @param {(order: ChartOrder) => void} onChange + */ +export function createOrderControl(currentOrder, onChange) { + return setting.create(currentOrder, onChange); +} + +/** + * @param {number[]} indexes + * @param {ChartOrder} order + */ +export function orderIndexes(indexes, order) { + const orderedIndexes = [...indexes]; + + if (order === "descending") orderedIndexes.reverse(); + + return orderedIndexes; +} + +/** + * @param {number} length + * @param {ChartOrder} order + */ +export function createOrderedIndexes(length, order) { + return orderIndexes( + Array.from({ length }, (_, index) => index), + order, + ); +} + +/** @typedef {(typeof orders)[number]["value"]} ChartOrder */ diff --git a/website_next/learn/charts/plot.js b/website_next/learn/charts/plot.js index 9a33a2fdc..8b9a07f68 100644 --- a/website_next/learn/charts/plot.js +++ b/website_next/learn/charts/plot.js @@ -1,3 +1,4 @@ +import { renderAreaPlot } from "./area/index.js"; import { renderBarPlot } from "./bar/index.js"; import { renderDotsPlot } from "./dots/index.js"; import { renderLinePlot } from "./line/index.js"; @@ -10,36 +11,61 @@ import { renderStackedPlot } from "./stacked/index.js"; * @param {number} height * @param {SeriesHighlight} highlight * @param {ChartScale} scale + * @param {ChartOrder} order */ -export function renderPlot(view, group, loadedSeries, height, highlight, scale) { +export function renderPlot( + view, + group, + loadedSeries, + height, + highlight, + scale, + order, +) { switch (view) { case "line": - return renderLinePlot(group, loadedSeries, height, highlight, scale); - case "bar": - case "bar-reversed": - return renderBarPlot( + return renderLinePlot( group, loadedSeries, height, highlight, - { reversed: view === "bar-reversed" }, scale, + order, ); + case "area": + return renderAreaPlot( + group, + loadedSeries, + height, + highlight, + scale, + order, + ); + case "bar": + return renderBarPlot(group, loadedSeries, height, highlight, scale, order); case "dots": - return renderDotsPlot(group, loadedSeries, height, highlight, scale); - default: + return renderDotsPlot( + group, + loadedSeries, + height, + highlight, + scale, + order, + ); + case "stacked": return renderStackedPlot( group, loadedSeries, height, highlight, - { reversed: view === "stacked-reversed" }, scale, + order, ); } } /** @typedef {import("./highlight.js").SeriesHighlight} SeriesHighlight */ /** @typedef {import("./index.js").LoadedSeries} LoadedSeries */ +/** @typedef {import("./order.js").ChartOrder} ChartOrder */ /** @typedef {import("./scale.js").ChartScale} ChartScale */ /** @typedef {import("./views.js").ChartView} ChartView */ diff --git a/website_next/learn/charts/renderer.js b/website_next/learn/charts/renderer.js index bbd1437d0..370f65853 100644 --- a/website_next/learn/charts/renderer.js +++ b/website_next/learn/charts/renderer.js @@ -15,6 +15,7 @@ import { getViewBoxHeight, VIEWBOX_WIDTH } from "./viewbox.js"; * @param {Chart} args.chart * @param {() => ChartView} args.getView * @param {() => ChartScale} args.getScale + * @param {() => ChartOrder} args.getOrder * @param {() => TimeframeValue} args.getTimeframe */ export function createChartRenderer({ @@ -26,6 +27,7 @@ export function createChartRenderer({ chart, getView, getScale, + getOrder, getTimeframe, }) { const group = createSvgElement("g"); @@ -68,6 +70,7 @@ export function createChartRenderer({ height, highlight, getScale(), + getOrder(), ), height, ); @@ -132,6 +135,7 @@ export function createChartRenderer({ /** @typedef {import("./index.js").Chart} Chart */ /** @typedef {import("./index.js").LoadedSeries} LoadedSeries */ /** @typedef {import("./legend/index.js").Readout} Readout */ +/** @typedef {import("./order.js").ChartOrder} ChartOrder */ /** @typedef {import("./scale.js").ChartScale} ChartScale */ /** @typedef {import("./timeframes.js").TimeframeValue} TimeframeValue */ /** @typedef {import("./views.js").ChartView} ChartView */ diff --git a/website_next/learn/charts/scale.js b/website_next/learn/charts/scale.js index 5d249eaaa..4c12a7bce 100644 --- a/website_next/learn/charts/scale.js +++ b/website_next/learn/charts/scale.js @@ -36,6 +36,24 @@ export function createScaleControl(currentScale, onChange) { return setting.create(currentScale, onChange); } +export function createBounds() { + return { + min: Infinity, + max: -Infinity, + minPositive: Infinity, + }; +} + +/** + * @param {ScaleBounds} bounds + * @param {number} value + */ +export function includeBoundValue(bounds, value) { + bounds.min = Math.min(bounds.min, value); + bounds.max = Math.max(bounds.max, value); + if (value > 0) bounds.minPositive = Math.min(bounds.minPositive, value); +} + /** * @param {number} value * @param {ScaleBounds} bounds diff --git a/website_next/learn/charts/stacked/index.js b/website_next/learn/charts/stacked/index.js index 374b551e1..bc3df5e1b 100644 --- a/website_next/learn/charts/stacked/index.js +++ b/website_next/learn/charts/stacked/index.js @@ -7,21 +7,21 @@ import { createStackedSeries } from "./series.js"; * @param {LoadedSeries[]} loadedSeries * @param {number} height * @param {SeriesHighlight} highlight - * @param {{ reversed: boolean }} options * @param {import("../scale.js").ChartScale} scale + * @param {import("../order.js").ChartOrder} order */ export function renderStackedPlot( group, loadedSeries, height, highlight, - options, scale, + order, ) { const { lineIndexes, plottedSeries, stackIndexes } = createStackedSeries( loadedSeries, height, - options.reversed, + order, scale, ); diff --git a/website_next/learn/charts/stacked/series.js b/website_next/learn/charts/stacked/series.js index 5199921e9..331c4c0f0 100644 --- a/website_next/learn/charts/stacked/series.js +++ b/website_next/learn/charts/stacked/series.js @@ -1,42 +1,40 @@ import { VIEWBOX_WIDTH } from "../viewbox.js"; -import { scaleY } from "../scale.js"; +import { orderIndexes } from "../order.js"; +import { createBounds, includeBoundValue, scaleY } from "../scale.js"; /** * @param {LoadedSeries[]} series - * @param {number[]} stackIndexes + * @param {number[]} stackOrder * @param {number[]} lineIndexes */ -function createStackBounds(series, stackIndexes, lineIndexes) { +function createStackBounds(series, stackOrder, lineIndexes) { + const bounds = createBounds(); const length = series[0].entries.length; - let min = 0; - let max = 0; - let minPositive = Infinity; + + includeBoundValue(bounds, 0); for (let index = 0; index < length; index += 1) { let negative = 0; let positive = 0; - for (const seriesIndex of stackIndexes) { + for (const seriesIndex of stackOrder) { const value = series[seriesIndex].entries[index].value; + const end = value < 0 ? negative + value : positive + value; - if (value < 0) negative += value; - else positive += value; + if (value < 0) negative = end; + else positive = end; + + includeBoundValue(bounds, end); } - min = Math.min(min, negative); - max = Math.max(max, positive); - if (positive > 0) minPositive = Math.min(minPositive, positive); - for (const seriesIndex of lineIndexes) { const value = series[seriesIndex].entries[index].value; - min = Math.min(min, value); - max = Math.max(max, value); - if (value > 0) minPositive = Math.min(minPositive, value); + includeBoundValue(bounds, value); } } - return { min, max, minPositive }; + return bounds; } /** @returns {StackedPoint[]} */ @@ -47,36 +45,36 @@ function createStackedPoints() { /** * @param {LoadedSeries[]} loadedSeries * @param {number} height - * @param {boolean} reversed + * @param {import("../order.js").ChartOrder} order * @param {import("../scale.js").ChartScale} scale */ -export function createStackedSeries(loadedSeries, height, reversed, scale) { +export function createStackedSeries(loadedSeries, height, order, scale) { const indexes = loadedSeries.map((_, index) => index); - const lineIndexes = indexes.filter( - (index) => loadedSeries[index].series.role === "line", + const lineIndexes = orderIndexes( + indexes.filter((index) => loadedSeries[index].series.role === "line"), + order, ); - const stackIndexes = indexes.filter( - (index) => loadedSeries[index].series.role !== "line", + const stackIndexes = orderIndexes( + indexes.filter((index) => loadedSeries[index].series.role !== "line"), + order, ); - const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes); const length = loadedSeries[0].entries.length; const xScale = VIEWBOX_WIDTH / (length - 1); - const order = [...stackIndexes]; const plottedSeries = loadedSeries.map(({ series, color }) => ({ series, color, points: createStackedPoints(), })); - if (reversed) order.reverse(); + const bounds = createStackBounds(loadedSeries, stackIndexes, lineIndexes); for (let index = 0; index < length; index += 1) { let negative = 0; let positive = 0; const x = index * xScale; - for (const seriesIndex of order) { + for (const seriesIndex of stackIndexes) { const { date, value } = loadedSeries[seriesIndex].entries[index]; const start = value < 0 ? negative : positive; const end = start + value; diff --git a/website_next/learn/charts/views.js b/website_next/learn/charts/views.js index 68732f48d..82e034a66 100644 --- a/website_next/learn/charts/views.js +++ b/website_next/learn/charts/views.js @@ -2,18 +2,16 @@ import { createChartSetting } from "./setting.js"; export const viewTypes = /** @type {const} */ ({ line: "line", + area: "area", stacked: "stacked", - stackedReversed: "stacked-reversed", bar: "bar", - barReversed: "bar-reversed", dots: "dots", }); const views = /** @type {const} */ ([ { value: viewTypes.line, label: "Line" }, - { value: viewTypes.stacked, label: "Stack↑" }, - { value: viewTypes.stackedReversed, label: "Stack↓" }, - { value: viewTypes.bar, label: "Bars↑" }, - { value: viewTypes.barReversed, label: "Bars↓" }, + { value: viewTypes.area, label: "Area" }, + { value: viewTypes.stacked, label: "Stack" }, + { value: viewTypes.bar, label: "Bars" }, { value: viewTypes.dots, label: "Dots" }, ]); const defaultView = viewTypes.stacked;