UNPKG

react-plot

Version:

Library of React components to render SVG 2D plots.

247 lines 9.17 kB
import { max, min } from 'd3-array'; import { scaleLinear, scaleLog, scaleOrdinal, scaleTime } from 'd3-scale'; import { createContext, useContext, useMemo } from 'react'; import { validatePosition } from '../utils.js'; export function plotReducer(state, action) { switch (action.type) { case 'addSeries': { state.series.push(action.payload); break; } case 'removeSeries': { const { id } = action.payload; const seriesFiltered = state.series.filter((series) => series.id !== id); state.series = seriesFiltered; break; } case 'addAxis': { const { id, position, ...values } = action.payload; const currentAxis = state.axes[id]; if (currentAxis) { validatePosition(currentAxis.position, position, id); state.axes[id] = { ...currentAxis, position, ...values }; } else { state.axes[id] = { id, position, ...values }; } break; } case 'removeAxis': { const { id } = action.payload; delete state.axes[id]; break; } case 'addHeading': { state.headingPosition = action.payload.position; break; } case 'removeHeading': { state.headingPosition = null; break; } case 'addLegend': { state.legendPosition = action.payload.position; state.legendMargin = action.payload.margin; break; } case 'removeLegend': { state.legendPosition = null; state.legendMargin = 0; break; } default: { // eslint-disable-next-line @typescript-eslint/no-explicit-any throw new Error(`Unknown reducer type ${action.type}`); } } } export const plotContext = createContext({ width: 0, height: 0, plotWidth: 0, plotHeight: 0, colorScaler: scaleOrdinal(), axisContext: {}, }); export const plotDispatchContext = createContext(() => { // No-op }); export function usePlotContext() { const context = useContext(plotContext); if (!context) { throw new Error('usePlotContext called outside of Plot context'); } return context; } export function usePlotDispatchContext() { const context = useContext(plotDispatchContext); if (!context) { throw new Error('usePlotDispatchContext called outside of Plot context'); } return context; } export function useAxisContext(state, axesOverrides, { plotWidth, plotHeight }) { const context = useMemo(() => { const axisContext = {}; for (const id in state.axes) { const axis = state.axes[id]; const overrides = axesOverrides[id]; const isHorizontal = ['top', 'bottom'].includes(axis.position); const xY = isHorizontal ? 'x' : 'y'; // Get axis boundaries from override (context), state (axis props), or data. let isAxisMinForced = false; let axisMin; if (overrides?.min != null) { axisMin = overrides.min; isAxisMinForced = true; } else if (axis.min != null) { axisMin = axis.min; isAxisMinForced = true; } else { axisMin = min(state.series.filter((s) => xY !== 'x' || !s.id.startsWith('~')), (d) => (d[xY].axisId === id ? d[xY].min : undefined)); } let isAxisMaxForced = false; let axisMax; if (overrides?.max != null) { axisMax = overrides.max; isAxisMaxForced = true; } else if (axis.max != null) { axisMax = axis.max; isAxisMaxForced = true; } else { axisMax = max(state.series.filter((s) => xY !== 'x' || !s.id.startsWith('~')), (d) => (d[xY].axisId === id ? d[xY].max : undefined)); } // Limits validation if (axisMin === undefined || axisMax === undefined) { return {}; } if (axisMin > axisMax) { throw new Error(`${id}: min (${axisMin}) is bigger than max (${axisMax})`); } const axisSize = isHorizontal ? plotWidth : plotHeight; const padding = computeAxisPadding(axis, axisMax - axisMin, axisSize, isAxisMinForced, isAxisMaxForced); const range = isHorizontal ? [0, plotWidth] : [plotHeight, 0]; const domain = [axisMin - padding.min, axisMax + padding.max]; // eslint-disable-next-line unicorn/consistent-function-scoping const clampInDomain = function clampInDomain(value) { return value < domain[0] ? domain[0] : Math.min(value, domain[1]); }; switch (axis.scale) { case 'log': { axisContext[id] = { type: axis.scale, position: axis.position, tickLabelFormat: axis.tickLabelFormat, scale: scaleLog() .domain(domain) .range(axis.flip ? range.reverse() : range), domain, clampInDomain, }; break; } case 'time': { axisContext[id] = { type: axis.scale, position: axis.position, tickLabelFormat: axis.tickLabelFormat, scale: scaleTime() .domain(domain) .range(axis.flip ? range.reverse() : range), domain, clampInDomain, }; break; } case 'linear': { axisContext[id] = { type: 'linear', position: axis.position, tickLabelFormat: axis.tickLabelFormat, scale: scaleLinear() .domain(domain) .range(axis.flip ? range.reverse() : range), domain, clampInDomain, }; break; } default: { throw new Error('unreachable'); } } } return axisContext; }, [state.axes, state.series, axesOverrides, plotWidth, plotHeight]); return context; } function computeAxisPadding(axis, diff, size, isMinForced, isMaxForced) { const { paddingStart, paddingEnd } = axis; if (isMinForced && isMaxForced) { // No padding when both min and max are forced. return { min: 0, max: 0 }; } else if (isMaxForced) { // Only handle min. const newPadding = convertAxisPadding(paddingStart, 0, diff, size); return { min: newPadding.start, max: 0 }; } else if (isMinForced) { // Only handle max. const newPadding = convertAxisPadding(0, paddingEnd, diff, size); return { min: 0, max: newPadding.end }; } else { // Handle both. const newPadding = convertAxisPadding(paddingStart, paddingEnd, diff, size); return { min: newPadding.start, max: newPadding.end }; } } function convertAxisPadding(paddingStart, paddingEnd, diff, size) { let finalPaddingStart = 0; let finalPaddingEnd = 0; // Padding as a number is an absolute value added to the current range. let totalKnown = diff; if (typeof paddingStart === 'number') { totalKnown += paddingStart; finalPaddingStart = paddingStart; } if (typeof paddingEnd === 'number') { totalKnown += paddingEnd; finalPaddingEnd = paddingEnd; } // Padding as a string is converted to a percentage of the total size. let percentStart = 0; let percentEnd = 0; if (typeof paddingStart === 'string') { const paddingStartPx = toPx(paddingStart, size); percentStart = paddingStartPx / size; } if (typeof paddingEnd === 'string') { const paddingEndPx = toPx(paddingEnd, size); percentEnd = paddingEndPx / size; } const totalPercent = percentStart + percentEnd; if (totalPercent !== 0) { const totalPadding = (totalPercent * totalKnown) / (1 - totalPercent); finalPaddingStart = (percentStart / totalPercent) * totalPadding; finalPaddingEnd = (percentEnd / totalPercent) * totalPadding; } return { start: finalPaddingStart, end: finalPaddingEnd, }; } function toPx(padding, size) { if (padding.endsWith('%')) { return (Number(padding.slice(0, -1)) / 100) * size; } else { return Number(padding); } } //# sourceMappingURL=plotContext.js.map