website: redesign part 23

This commit is contained in:
nym21
2026-06-07 16:46:53 +02:00
parent 33cc13708a
commit 36cfe49b20
15 changed files with 328 additions and 73 deletions
+1
View File
@@ -103,6 +103,7 @@
<link rel="stylesheet" href="/learn/charts/controls/style.css" />
<link rel="stylesheet" href="/learn/charts/legend/style.css" />
<link rel="stylesheet" href="/learn/charts/scrubber/style.css" />
<link rel="stylesheet" href="/learn/charts/area/style.css" />
<link rel="stylesheet" href="/learn/charts/bar/style.css" />
<link rel="stylesheet" href="/learn/charts/line/style.css" />
<link rel="stylesheet" href="/learn/charts/dots/style.css" />
+62
View File
@@ -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 */
+9
View File
@@ -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;
}
}
}
+3 -3
View File
@@ -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,
);
+14 -3
View File
@@ -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;
}
+73 -11
View File
@@ -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);
+14 -3
View File
@@ -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;
}
+4 -8
View File
@@ -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;
}
/**
+59
View File
@@ -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 */
+35 -9
View File
@@ -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 */
+4
View File
@@ -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 */
+18
View File
@@ -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
+3 -3
View File
@@ -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,
);
+25 -27
View File
@@ -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;
+4 -6
View File
@@ -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;