diff --git a/src/charts/BarChart.ts b/src/charts/BarChart.ts index 05734a5..e2ca3fc 100644 --- a/src/charts/BarChart.ts +++ b/src/charts/BarChart.ts @@ -110,6 +110,22 @@ const getMaxValue = (step: BarInputStep): ExtremeValue => { return getExtremeValue(step.valueScale?.maxValue, valueMax); }; +export const xExtent = (inputStep: BarInputStep): Chart.Extent => { + if (inputStep.layout === "horizontal") { + const maxValue = getMaxValue(inputStep); + + return [0, maxValue.actual]; + } +}; + +export const yExtent = (inputStep: BarInputStep): Chart.Extent => { + if (inputStep.layout === undefined || inputStep.layout === "vertical") { + const maxValue = getMaxValue(inputStep); + + return [0, maxValue.actual]; + } +}; + export const updateDims = (info: Info, dims: Dimensions, svg: Svg) => { const { subtype, layout, maxValue, shouldRotateLabels, maxGroupLabelWidth } = info; diff --git a/src/charts/BeeswarmChart.ts b/src/charts/BeeswarmChart.ts index 26b58a5..5aa85da 100644 --- a/src/charts/BeeswarmChart.ts +++ b/src/charts/BeeswarmChart.ts @@ -73,6 +73,40 @@ export const info = ( }; }; +export const xExtent = (inputStep: BeeswarmInputStep): Chart.Extent => { + const { layout, positionScale } = inputStep; + + if (layout === "vertical") { + return; + } + + const positions = inputStep.groups.flatMap((g) => { + return g.data.map((d) => d.position); + }); + + return [ + positionScale?.minValue ?? min(positions) ?? 0, + positionScale?.maxValue ?? max(positions) ?? 0, + ]; +}; + +export const yExtent = (inputStep: BeeswarmInputStep): Chart.Extent => { + const { layout, positionScale } = inputStep; + + if (layout === "horizontal") { + return; + } + + const positions = inputStep.groups.flatMap((g) => { + return g.data.map((d) => d.position); + }); + + return [ + positionScale?.minValue ?? min(positions) ?? 0, + positionScale?.maxValue ?? max(positions) ?? 0, + ]; +}; + export const updateDims = (dims: Dimensions) => { const { BASE_MARGIN } = dims; dims.addTop(BASE_MARGIN).addBottom(BASE_MARGIN); diff --git a/src/charts/BubbleChart.ts b/src/charts/BubbleChart.ts index a62f228..e3e0a03 100644 --- a/src/charts/BubbleChart.ts +++ b/src/charts/BubbleChart.ts @@ -43,6 +43,14 @@ export const info = ( }; }; +export const xExtent = (): Chart.Extent => { + return; +}; + +export const yExtent = (): Chart.Extent => { + return; +}; + export const updateDims = (dims: Dimensions) => { const { BASE_MARGIN } = dims; dims.addBottom(BASE_MARGIN); @@ -54,13 +62,8 @@ export const getters = ( ): Chart.Getter[] => { const { groups, maxValue, shareDomain, showValues, svgBackgroundColor } = info; - const { - showDatumLabels, - dims: { width, height, size, margin }, - textTypeDims, - colorMap, - cartoonize, - } = props; + const { showDatumLabels, dims, textTypeDims, colorMap, cartoonize } = props; + const { width, height, size, margin } = dims; const root = getHierarchyRoot({ groups, size: maxValue.k * size }); const groupsGetters: Chart.Getter[] = []; // If a custom maxValue was provided, we need to shift the bubbles to the center. diff --git a/src/charts/Chart.ts b/src/charts/Chart.ts index 84d4d7a..2060e5f 100644 --- a/src/charts/Chart.ts +++ b/src/charts/Chart.ts @@ -11,7 +11,7 @@ import { ColorMap } from "../colors"; import * as Generic from "../components/Generic"; import { Svg } from "../components/Svg"; import { Tooltip } from "../components/Tooltip"; -import { Dimensions, ResolvedDimensions } from "../dims"; +import { Dimensions } from "../dims"; import { InputStep, TextTypeDims } from "../types"; import { FONT_WEIGHT, stateOrderComparator, unique } from "../utils"; import * as Datum from "./Datum"; @@ -35,7 +35,13 @@ export const baseInfo = ( ); const showValues = inputStep.showValues ?? false; - return { groupsKeys, dataKeys, shareDomain, showValues, svgBackgroundColor }; + return { + groupsKeys, + dataKeys, + shareDomain, + showValues, + svgBackgroundColor, + }; }; export const info = ( @@ -65,6 +71,48 @@ export const info = ( export type Info = ReturnType; +export type Extent = [number, number] | undefined; + +export const xExtent = (inputStep: InputStep): Extent => { + switch (inputStep.chartType) { + case "bar": + return BarChart.xExtent(inputStep); + case "beeswarm": + return BeeswarmChart.xExtent(inputStep); + case "bubble": + return BubbleChart.xExtent(); + case "pie": + return PieChart.xExtent(); + case "scatter": + return ScatterChart.xExtent(inputStep); + case "treemap": + return TreemapChart.xExtent(); + default: + const _exhaustiveCheck: never = inputStep; + return _exhaustiveCheck; + } +}; + +export const yExtent = (inputStep: InputStep): Extent => { + switch (inputStep.chartType) { + case "bar": + return BarChart.yExtent(inputStep); + case "beeswarm": + return BeeswarmChart.yExtent(inputStep); + case "bubble": + return BubbleChart.yExtent(); + case "pie": + return PieChart.yExtent(); + case "scatter": + return ScatterChart.yExtent(inputStep); + case "treemap": + return TreemapChart.yExtent(); + default: + const _exhaustiveCheck: never = inputStep; + return _exhaustiveCheck; + } +}; + export const updateDims = (info: Info, dims: Dimensions, svg: Svg) => { switch (info.type) { case "bar": @@ -105,7 +153,7 @@ export type Getter = Generic.Getter; export type GetterProps = { showDatumLabels: boolean; svg: Svg; - dims: ResolvedDimensions; + dims: Dimensions; textTypeDims: TextTypeDims; colorMap: ColorMap; cartoonize: boolean; diff --git a/src/charts/PieChart.ts b/src/charts/PieChart.ts index ad0ea96..beed7dd 100644 --- a/src/charts/PieChart.ts +++ b/src/charts/PieChart.ts @@ -49,6 +49,14 @@ export const info = ( }; }; +export const xExtent = (): Chart.Extent => { + return; +}; + +export const yExtent = (): Chart.Extent => { + return; +}; + export const updateDims = (dims: Dimensions) => { const { BASE_MARGIN } = dims; dims.addBottom(BASE_MARGIN); @@ -60,13 +68,8 @@ export const getters = ( ): Chart.Getter[] => { const { groups, maxValue, shareDomain, showValues, svgBackgroundColor } = info; - const { - showDatumLabels, - dims: { width, height, size, margin }, - textTypeDims, - colorMap, - cartoonize, - } = props; + const { showDatumLabels, dims, textTypeDims, colorMap, cartoonize } = props; + const { width, height, size, margin } = dims; const root = getHierarchyRoot({ groups, size: maxValue.k * size }); const groupsGetters: Chart.Getter[] = []; const maxValueShift = maxValue.kc * size * 0.5; diff --git a/src/charts/ScatterChart.ts b/src/charts/ScatterChart.ts index 70fb9e2..78114d2 100644 --- a/src/charts/ScatterChart.ts +++ b/src/charts/ScatterChart.ts @@ -50,19 +50,41 @@ export const info = ( const type: ChartType = "scatter"; const xValues = groups.flatMap((d) => d.data.map((d) => d.x)); const yValues = groups.flatMap((d) => d.data.map((d) => d.y)); + const minValue = getMinValue(xValues, yValues, inputStep); + const maxValue = getMaxValue(xValues, yValues, inputStep); return { ...storyInfo, ...Chart.baseInfo(svgBackgroundColor, inputStep, shareDomain), type, groups, - minValue: getMinValue(xValues, yValues, inputStep), - maxValue: getMaxValue(xValues, yValues, inputStep), + minValue, + maxValue, verticalAxis: inputStep.verticalAxis, horizontalAxis: inputStep.horizontalAxis, }; }; +export const xExtent = (inputStep: ScatterInputStep): Chart.Extent => { + const { groups } = inputStep; + const xValues = groups.flatMap((d) => d.data.map((d) => d.x)); + const yValues = groups.flatMap((d) => d.data.map((d) => d.y)); + const minValue = getMinValue(xValues, yValues, inputStep); + const maxValue = getMaxValue(xValues, yValues, inputStep); + + return [minValue.x.actual, maxValue.x.actual]; +}; + +export const yExtent = (inputStep: ScatterInputStep): Chart.Extent => { + const { groups } = inputStep; + const xValues = groups.flatMap((d) => d.data.map((d) => d.x)); + const yValues = groups.flatMap((d) => d.data.map((d) => d.y)); + const minValue = getMinValue(xValues, yValues, inputStep); + const maxValue = getMaxValue(xValues, yValues, inputStep); + + return [minValue.y.actual, maxValue.y.actual]; +}; + const getMinValue = ( xValues: number[], yValues: number[], diff --git a/src/charts/TreemapChart.ts b/src/charts/TreemapChart.ts index 10d884f..ba17192 100644 --- a/src/charts/TreemapChart.ts +++ b/src/charts/TreemapChart.ts @@ -57,6 +57,14 @@ export const info = ( }; }; +export const xExtent = (): Chart.Extent => { + return; +}; + +export const yExtent = (): Chart.Extent => { + return; +}; + export const updateDims = (dims: Dimensions) => { const { BASE_MARGIN } = dims; dims.addBottom(BASE_MARGIN); diff --git a/src/components/Annotation.ts b/src/components/Annotation.ts new file mode 100644 index 0000000..7bafee9 --- /dev/null +++ b/src/components/Annotation.ts @@ -0,0 +1,282 @@ +import { ScaleLinear, scaleLinear } from "d3-scale"; +import { Selection } from "d3-selection"; +import { getPathData } from "../coords"; +import { Dimensions } from "../dims"; +import { InputAnnotation } from "../types"; +import { TextDims, deriveSubtlerColor, getTextColor, hexToRgb } from "../utils"; +import * as Generic from "./Generic"; +import { Svg } from "./Svg"; +import * as Text from "./Text"; + +export type Info = { + getX: ScaleLinear | undefined; + getY: ScaleLinear | undefined; +}; + +export const info = ( + xExtent: [number, number] | undefined, + yExtent: [number, number] | undefined, + dims: Dimensions +): Info => { + const getX = xExtent + ? scaleLinear().domain(xExtent).range([0, dims.width]) + : undefined; + const getY = yExtent + ? scaleLinear().domain(yExtent).range([dims.height, 0]) + : undefined; + + return { + getX, + getY, + }; +}; + +export type G = { + d: string; + x: number; + y: number; + width: number; + fill: string; + color: string; +}; + +export type Getter = Generic.Getter; + +export const getters = ({ + info, + annotations, + annotationDims, + dims, + svg, + svgBackgroundColor, +}: { + info: Info; + annotations: InputAnnotation[]; + annotationDims: TextDims; + dims: Dimensions; + svg: Svg; + svgBackgroundColor: string; +}): Getter[] => { + const { getX, getY } = info; + + return annotations + .filter((d) => { + return ( + (d.layout === "horizontal" && getY) || (d.layout === "vertical" && getX) + ); + }) + .map((d) => { + const { + key, + text, + layout, + textAnchor: inputTextAnchor = "end", + fill: inputFill, + size = 3, + } = d; + const isHorizontal = layout === "horizontal"; + const textAnchor = !isHorizontal + ? inputTextAnchor === "end" + ? "start" + : inputTextAnchor === "start" + ? "end" + : "middle" + : inputTextAnchor; + const textDims = annotationDims[key]; + const { + width, + height, + fullWidth, + fullHeight, + left, + top, + right, + bottom, + BASE_MARGIN, + } = dims; + const x = isHorizontal ? left + width * 0.5 : left + getX!(d.x); + const y = isHorizontal + ? top + getY!(d.y) + : top + height * 0.5 - textDims.height * 0.5 - BASE_MARGIN; + const fill = + inputFill ?? + deriveSubtlerColor( + getTextColor(svgBackgroundColor) === "black" ? "#000000" : "#FFFFFF" + ); + const color = getTextColor(fill) === "black" ? "#000000" : "#FFFFFF"; + + const labelGetter = text + ? Text.getter({ + type: "annotationLabel", + text, + anchor: isHorizontal ? "end" : textAnchor, + svg, + svgBackgroundColor: fill, + dims: { + ...dims, + fullWidth: + textAnchor === "middle" && !isHorizontal + ? fullWidth + (x - fullWidth * 0.5) * 2 + : fullWidth, + margin: { + ...dims.margin, + top: + y - + (isHorizontal + ? textAnchor === "middle" + ? textDims.height * 0.5 + : textAnchor === "start" + ? textDims.height + : 0 + : textDims.height + + height * 0.5 - + textDims.height * 0.5 + + BASE_MARGIN), + left: x, + right: + textAnchor === "end" && !isHorizontal ? fullWidth - x : right, + }, + } as Dimensions, + textDims, + }) + : undefined; + + return { + key, + g: ({ s }) => { + const g: G = { + d: + layout === "horizontal" + ? getPathData({ + type: "bar", + width, + height: size, + cartoonize: false, + }) + : getPathData({ + type: "bar", + width: size, + height: height + textDims.height + BASE_MARGIN * 2, + cartoonize: false, + }), + x, + y, + width: dims.width, + fill: s(`rgba(${hexToRgb(fill)}, 0)`, fill), + color: s(`rgba(${hexToRgb(color)}, 0)`, color), + }; + + return g; + }, + label: labelGetter, + }; + }); +}; + +export type Int = Generic.Int; + +export const ints = ({ + getters, + _getters, + _ints, +}: Generic.IntsProps) => { + return Generic.ints()({ + getters, + _getters, + _ints, + modifyInt: ({ getter, int, _updateInt }) => { + const _getter = _getters?.find((d) => d.key === getter.key); + const _labelGetter = _getter?.label; + + return { + ...int, + labels: Text.ints({ + getters: getter.label ? [getter.label] : [], + _getters: _labelGetter ? [_labelGetter] : [], + _ints: _updateInt?.labels, + }), + }; + }, + }); +}; + +export type Resolved = Generic.Resolved; + +export const resolve = ({ + ints, + t, +}: { + ints: Int[]; + t: number; +}): Resolved[] => { + return Generic.resolve()({ + ints, + t, + modifyResolved: ({ int, resolved }) => { + return { + ...resolved, + labels: Text.resolve({ ints: int.labels, t }).map((d) => { + return { + ...d, + color: resolved.color, + }; + }), + }; + }, + }); +}; + +export const render = ({ + selection, + resolved, +}: { + selection: Selection; + resolved: Resolved[]; +}): void => { + selection + .selectAll(".plotteus-annotation") + .data(resolved, (d) => d.key) + .join("g") + .attr("class", "plotteus-annotation") + .call((g) => + g + .selectAll("path") + .data((d) => [d]) + .join("path") + .attr("transform", (d) => `translate(${d.x}, ${d.y})`) + .attr("d", (d) => d.d) + .attr("fill", (d) => d.fill) + ) + .call((g) => + g + .selectAll("foreignObject") + .data( + (d) => d.labels, + (d) => d.key + ) + .join("foreignObject") + .attr("x", (d) => d.x) + .attr("y", (d) => d.y) + .attr("width", (d) => d.width) + .attr("height", (d) => d.height) + .selectAll("div") + .data((d) => [d]) + .join("xhtml:div") + .style("color", (d) => d.color) + .style("line-height", 1.5) + .style("font-size", (d) => `${d.fontSize}px`) + .style("font-weight", (d) => d.fontWeight) + .text((d) => d.key) + ) + .call((g) => + g + .selectAll("div") + .data((d) => [d]) + .style("background", (d) => d.fill) + // TODO: share this on a higher level + .style("padding-left", "4px") + .style("padding-top", "2px") + .style("padding-right", "4px") + .style("padding-bottom", "2px") + ); +}; diff --git a/src/components/Axis.ts b/src/components/Axis.ts index f3b0a25..f694b04 100644 --- a/src/components/Axis.ts +++ b/src/components/Axis.ts @@ -154,7 +154,6 @@ export const getters = ({ maxValue: number; _maxValue: number | undefined; }): Getter => { - const resolvedDims = dims.resolve(); const ticks = scaleLinear().domain([minValue, maxValue]).ticks(ticksCount); const titleDims = svg.measureText(title, "axisTitle", { paddingLeft: dims.BASE_MARGIN, @@ -167,7 +166,10 @@ export const getters = ({ anchor: type === "vertical" ? "start" : "end", svg, svgBackgroundColor, - resolvedDims: { ...resolvedDims, margin: titleMargin }, + dims: { + ...dims, + margin: titleMargin, + } as Dimensions, textDims: titleDims, }) : undefined; @@ -204,7 +206,7 @@ export const getters = ({ _maxValue, tickHeight, tickFormat, - dims: resolvedDims, + dims, svgBackgroundColor, }), }; @@ -248,7 +250,13 @@ export type Resolved = Generic.Resolved< { titles: Text.Resolved[]; ticks: Tick.Resolved[] } >; -export const resolve = ({ ints, t }: { ints: Int[]; t: number }) => { +export const resolve = ({ + ints, + t, +}: { + ints: Int[]; + t: number; +}): Resolved[] => { return Generic.resolve()({ ints, t, diff --git a/src/components/AxisTick.ts b/src/components/AxisTick.ts index 9e34d04..85de7ba 100644 --- a/src/components/AxisTick.ts +++ b/src/components/AxisTick.ts @@ -1,7 +1,7 @@ import { scaleLinear } from "d3-scale"; import { Selection } from "d3-selection"; import { HALF_FONT_K } from "../charts/utils"; -import { ResolvedDimensions } from "../dims"; +import { Dimensions } from "../dims"; import { AxisType } from "../types"; import { FONT_SIZE, @@ -49,7 +49,7 @@ export const getters = ({ _minValue: number | undefined; maxValue: number; _maxValue: number | undefined; - dims: ResolvedDimensions; + dims: Dimensions; svgBackgroundColor: string; tickHeight: number; tickFormat: (d: number) => string; diff --git a/src/components/ColorLegend.ts b/src/components/ColorLegend.ts index bf4b869..99997bc 100644 --- a/src/components/ColorLegend.ts +++ b/src/components/ColorLegend.ts @@ -1,7 +1,7 @@ import * as Chart from "../charts/Chart"; import { HALF_FONT_K } from "../charts/utils"; import { ColorMap } from "../colors"; -import { Dimensions, ResolvedDimensions } from "../dims"; +import { Dimensions } from "../dims"; import { Anchor, InputDatum, InputStep } from "../types"; import { FONT_SIZE, FONT_WEIGHT, getTextColor, max } from "../utils"; import * as Generic from "./Generic"; @@ -75,7 +75,7 @@ export const getters = ({ itemHeight: number; svg: Svg; svgBackgroundColor: string; - dims: ResolvedDimensions; + dims: Dimensions; }): Getter[] => { const getters: Getter[] = []; const colorsWithCoords: { diff --git a/src/components/Step.ts b/src/components/Step.ts index 8c5f704..c47fb6e 100644 --- a/src/components/Step.ts +++ b/src/components/Step.ts @@ -1,10 +1,10 @@ -import { Axis, AxisTick, ColorLegend, Svg, Text, Tooltip } from "."; +import { Annotation, Axis, AxisTick, ColorLegend, Svg, Text, Tooltip } from "."; import * as Story from ".."; import * as Chart from "../charts/Chart"; import { ColorMap } from "../colors"; import { Dimensions } from "../dims"; import { InputStep, StoryOptions } from "../types"; -import { stateOrderComparator } from "../utils"; +import { max, stateOrderComparator } from "../utils"; export type Getter = { key: string; @@ -14,6 +14,7 @@ export type Getter = { colorLegends: ColorLegend.Getter[] | undefined; verticalAxis: Axis.Getter | undefined; horizontalAxis: Axis.Getter | undefined; + annotations: Annotation.Getter[] | undefined; }; export const getters = ({ @@ -31,7 +32,7 @@ export const getters = ({ width: number; height: number; }): Getter[] => { - const { textTypeDims } = storyInfo; + const { textTypeDims, annotationDims } = storyInfo; const { svgBackgroundColor } = storyOptions; const getters: Getter[] = []; let _minHorizontalAxisValue: number | undefined; @@ -51,20 +52,18 @@ export const getters = ({ legendAnchor = "middle", showDatumLabels = false, cartoonize = false, + annotations, } = step; const dims = new Dimensions(width, height); - const chartInfo = Chart.info(storyInfo, svgBackgroundColor, step, dims); - const colorLegendInfo = ColorLegend.info(step, chartInfo, colorMap); - const verticalAxisInfo = Axis.info("vertical", chartInfo); - const horizontalAxisInfo = Axis.info("horizontal", chartInfo); + const xExtent = Chart.xExtent(step); + const yExtent = Chart.yExtent(step); let titleGetter: Text.Getter | undefined; if (title !== undefined) { - const resolvedDims = dims.resolve(); const titleDims = svg.measureText(title, "title", { - paddingLeft: resolvedDims.margin.left, - paddingRight: resolvedDims.margin.right, + paddingLeft: dims.margin.left, + paddingRight: dims.margin.right, }); titleGetter = Text.getter({ svg, @@ -72,7 +71,7 @@ export const getters = ({ text: title, type: "title", anchor: titleAnchor, - resolvedDims, + dims, textDims: titleDims, }); Text.updateDims({ @@ -90,10 +89,9 @@ export const getters = ({ let subtitleGetter: Text.Getter | undefined; if (subtitle !== undefined) { - const resolvedDims = dims.resolve(); const subtitleDims = svg.measureText(subtitle, "subtitle", { - paddingLeft: resolvedDims.margin.left, - paddingRight: resolvedDims.margin.right, + paddingLeft: dims.margin.left, + paddingRight: dims.margin.right, }); subtitleGetter = Text.getter({ svg, @@ -101,7 +99,7 @@ export const getters = ({ text: subtitle, type: "subtitle", anchor: subtitleAnchor, - resolvedDims, + dims, textDims: subtitleDims, }); Text.updateDims({ @@ -117,6 +115,53 @@ export const getters = ({ dims.addTop(dims.BASE_MARGIN); } + // Need to take max annotation width into account before calculating chart info, + // because bar chart determines whether or not to rotate the labels based on the + // width of the chart. + let annotationMaxWidth: number | undefined; + const horizontalAnnotations = annotations?.filter( + (d) => d.layout === "horizontal" + ); + + if (horizontalAnnotations?.length && yExtent) { + const maxWidth = max( + horizontalAnnotations.map((d) => { + return annotationDims[d.key].width; + }) + ); + + if (maxWidth !== undefined && maxWidth > 0) { + annotationMaxWidth = maxWidth; + dims.addRight(annotationMaxWidth + dims.BASE_MARGIN); + } + } + + let annotationMaxHeight: number | undefined; + const verticalAnnotations = annotations?.filter( + (d) => d.layout === "vertical" + ); + + if (verticalAnnotations?.length && xExtent) { + const maxHeight = max( + verticalAnnotations.map((d) => { + return annotationDims[d.key].height; + }) + ); + + if (maxHeight !== undefined && maxHeight > 0) { + annotationMaxHeight = maxHeight; + dims.addTop(annotationMaxHeight + dims.BASE_MARGIN); + } + } + + const chartInfo = Chart.info(storyInfo, svgBackgroundColor, step, dims); + if (annotationMaxWidth !== undefined) { + dims.addRight(-(annotationMaxWidth + dims.BASE_MARGIN)); + } + const colorLegendInfo = ColorLegend.info(step, chartInfo, colorMap); + const verticalAxisInfo = Axis.info("vertical", chartInfo); + const horizontalAxisInfo = Axis.info("horizontal", chartInfo); + let colorLegendsGetters: ColorLegend.Getter[] | undefined; if (colorLegendInfo.show) { colorLegendsGetters = ColorLegend.getters({ @@ -126,7 +171,7 @@ export const getters = ({ itemHeight: textTypeDims.legendItem.height, svg, svgBackgroundColor, - dims: dims.resolve(), + dims, }); ColorLegend.updateDims({ dims, @@ -142,7 +187,7 @@ export const getters = ({ paddingLeft: dims.BASE_MARGIN, paddingRight: dims.BASE_MARGIN, }); - const ticksCount = Axis.getTicksCount(dims.resolve().height); + const ticksCount = Axis.getTicksCount(dims.height); Axis.updateDims({ type: "vertical", dims, @@ -167,7 +212,7 @@ export const getters = ({ paddingLeft: dims.BASE_MARGIN, paddingRight: dims.BASE_MARGIN, }); - const ticksCount = Axis.getTicksCount(dims.resolve().width); + const ticksCount = Axis.getTicksCount(dims.width); const width = Axis.getWidth({ svg, ticksCount, @@ -187,15 +232,13 @@ export const getters = ({ tickFormat, addTopMargin, }); - const resolvedDims = dims.resolve(); horizontalAxisGetters = Axis.getters({ type: "horizontal", title, titleMargin: { top: dims.BASE_MARGIN * 1.5 + AxisTick.SIZE + AxisTick.LABEL_MARGIN, - right: - resolvedDims.margin.right + resolvedDims.margin.left - width * 0.5, + right: dims.margin.right + dims.margin.left - width * 0.5, bottom: 0, left: 0, }, @@ -218,11 +261,15 @@ export const getters = ({ _maxHorizontalAxisValue = undefined; } + if (annotationMaxWidth !== undefined) { + dims.addRight(annotationMaxWidth + dims.BASE_MARGIN); + } + let verticalAxisGetters: Axis.Getter | undefined; if (verticalAxisInfo.show) { const { title, tickFormat, minValue, maxValue, addTopMargin } = verticalAxisInfo; - const ticksCount = Axis.getTicksCount(dims.resolve().height); + const ticksCount = Axis.getTicksCount(dims.height); const width = Axis.getWidth({ svg, ticksCount, @@ -277,12 +324,29 @@ export const getters = ({ const groupsGetters = Chart.getters(chartInfo, { showDatumLabels, svg, - dims: dims.resolve(), + dims, textTypeDims, colorMap, cartoonize, }); + if (annotationMaxWidth !== undefined) { + dims.addRight(-annotationMaxWidth - dims.BASE_MARGIN); + } + + let annotationsGetters: Annotation.Getter[] | undefined; + if (annotations) { + const info = Annotation.info(xExtent, yExtent, dims); + annotationsGetters = Annotation.getters({ + info, + annotations, + annotationDims, + dims, + svg, + svgBackgroundColor, + }); + } + getters.push({ key, title: titleGetter, @@ -291,6 +355,7 @@ export const getters = ({ horizontalAxis: horizontalAxisGetters, verticalAxis: verticalAxisGetters, groups: groupsGetters, + annotations: annotationsGetters, }); } @@ -304,6 +369,7 @@ export type Int = { colorLegends: ColorLegend.Int[]; horizontalAxes: Axis.Int[]; verticalAxes: Axis.Int[]; + annotations: Annotation.Int[]; }; export type IntsMap = Map; @@ -322,6 +388,7 @@ export const intsMap = ({ let _colorLegendInts: ColorLegend.Int[] | undefined; let _verticalAxisInts: Axis.Int[] | undefined; let _horizontalAxisInts: Axis.Int[] | undefined; + let _annotationInts: Annotation.Int[] | undefined; steps.forEach((step, i) => { const { @@ -332,6 +399,7 @@ export const intsMap = ({ colorLegends, horizontalAxis, verticalAxis, + annotations, } = step; const _stepGetters: Getter | undefined = steps[i - 1]; @@ -375,6 +443,12 @@ export const intsMap = ({ _ints: _groupInts, }); + const annotationInts = Annotation.ints({ + getters: annotations, + _getters: _stepGetters?.annotations, + _ints: _annotationInts, + }); + intsMap.set(key, { titles: (_titleInts = titlesInts), subtitles: (_subtitleInts = subtitlesInts), @@ -383,6 +457,7 @@ export const intsMap = ({ colorLegends: (_colorLegendInts = colorLegendsInts), horizontalAxes: (_horizontalAxisInts = horizontalAxesInts), verticalAxes: (_verticalAxisInts = verticalAxesInts), + annotations: (_annotationInts = annotationInts), }); }); @@ -396,9 +471,10 @@ export type Resolved = { colors: ColorLegend.Resolved[]; horizontalAxes: Axis.Resolved[]; verticalAxes: Axis.Resolved[]; + annotations: Annotation.Resolved[]; }; -export const resolve = (ints: Int, t: number) => { +export const resolve = (ints: Int, t: number): Resolved => { const { titles, subtitles, @@ -406,6 +482,7 @@ export const resolve = (ints: Int, t: number) => { colorLegends, horizontalAxes, verticalAxes, + annotations, } = ints; return { @@ -415,6 +492,7 @@ export const resolve = (ints: Int, t: number) => { colors: ColorLegend.resolve({ ints: colorLegends, t }), horizontalAxes: Axis.resolve({ ints: horizontalAxes, t }), verticalAxes: Axis.resolve({ ints: verticalAxes, t }), + annotations: Annotation.resolve({ ints: annotations, t }), }; }; @@ -433,8 +511,15 @@ export const render = ({ finished: boolean; indicateProgress: boolean; }) => { - const { titles, subtitles, colors, horizontalAxes, verticalAxes, groups } = - resolved; + const { + titles, + subtitles, + colors, + horizontalAxes, + verticalAxes, + groups, + annotations, + } = resolved; Text.render({ selection: svg.selection, @@ -471,6 +556,11 @@ export const render = ({ tooltip: finished ? tooltip : undefined, }); + Annotation.render({ + resolved: annotations, + selection: svg.selection, + }); + if (finished) { tooltip.node.raise(); diff --git a/src/components/Svg.ts b/src/components/Svg.ts index c3963a0..34cc832 100644 --- a/src/components/Svg.ts +++ b/src/components/Svg.ts @@ -3,8 +3,11 @@ import { StoryOptions, TextType } from "../types"; import { FONT_SIZE, FONT_WEIGHT } from "../utils"; export type MeasureTextOptions = { + maxWidth?: number; paddingLeft?: number; + paddingTop?: number; paddingRight?: number; + paddingBottom?: number; }; export type Svg = { @@ -47,7 +50,15 @@ export const createSvg = (div: HTMLDivElement, options: StoryOptions): Svg => { options?: MeasureTextOptions ): DOMRect => { const { width } = measure(); - const { paddingLeft = 0, paddingRight = 0 } = options ?? {}; + const { + paddingLeft = 0, + paddingTop = 0, + paddingRight = 0, + paddingBottom = 0, + } = options ?? {}; + const maxWidth = options?.maxWidth + ? Math.min(width, options.maxWidth) + : width; const root = select(div) .append("div") .attr("aria-hidden", "true") @@ -58,13 +69,15 @@ export const createSvg = (div: HTMLDivElement, options: StoryOptions): Svg => { .style("opacity", 0) .style("pointer-events", "none") .style("box-sizing", "border-box") - .style("max-width", `${width}px`) - .style("padding-left", `${paddingLeft}px`) - .style("padding-right", `${paddingRight}px`); + .style("max-width", `${maxWidth}px`); const node = root .append("div") .style("width", "fit-content") .style("height", "fit-content") + .style("padding-left", `${paddingLeft}px`) + .style("padding-top", `${paddingTop}px`) + .style("padding-right", `${paddingRight}px`) + .style("padding-bottom", `${paddingBottom}px`) .style("line-height", 1.5) .style("font-size", `${FONT_SIZE[textType]}px`) .style("font-weight", FONT_WEIGHT[textType]) @@ -75,8 +88,8 @@ export const createSvg = (div: HTMLDivElement, options: StoryOptions): Svg => { return { ...rect, - width: rect.width + 1, - height: rect.height + 1, + width: Math.ceil(rect.width), + height: Math.ceil(rect.height), }; }; diff --git a/src/components/Text.spec.ts b/src/components/Text.spec.ts index e766658..6dce4e0 100644 --- a/src/components/Text.spec.ts +++ b/src/components/Text.spec.ts @@ -11,7 +11,7 @@ describe("Text", () => { text: "Hello, Plotteus!", type: "title", anchor: "middle", - resolvedDims: dims.resolve(), + dims, svgBackgroundColor: "#FFFFFF", textDims, }); @@ -90,7 +90,7 @@ describe("Text", () => { text: "Hello, Plotteus!", type: "datumLabel", anchor: "start", - resolvedDims: dims.resolve(), + dims, svgBackgroundColor: "white", textDims, }); diff --git a/src/components/Text.ts b/src/components/Text.ts index 5aad8be..4f48556 100644 --- a/src/components/Text.ts +++ b/src/components/Text.ts @@ -1,5 +1,5 @@ import { Selection } from "d3-selection"; -import { Dimensions, ResolvedDimensions } from "../dims"; +import { Dimensions } from "../dims"; import { Anchor, TextType } from "../types"; import { FONT_SIZE, FONT_WEIGHT, getTextColor, hexToRgb } from "../utils"; import * as Generic from "./Generic"; @@ -23,7 +23,7 @@ export const getter = ({ anchor, svg, svgBackgroundColor, - resolvedDims: { fullWidth, margin }, + dims: { fullWidth, margin }, textDims, }: { text: string; @@ -31,7 +31,7 @@ export const getter = ({ anchor: Anchor; svg: Svg; svgBackgroundColor: string; - resolvedDims: ResolvedDimensions; + dims: Dimensions; textDims: DOMRect; }): Getter => { return { @@ -50,6 +50,9 @@ export const getter = ({ break; } + const color = + getTextColor(svgBackgroundColor) === "black" ? "#000000" : "#FFFFFF"; + const g: G = { x: s(x, null, _g?.x), y: s(margin.top, null, _g?.y), @@ -57,10 +60,7 @@ export const getter = ({ height: s(textDims.height, null, _g?.height), fontSize: FONT_SIZE[type], fontWeight: FONT_WEIGHT[type], - color: s( - `rgba(${hexToRgb(svgBackgroundColor)}, 0)`, - getTextColor(svgBackgroundColor) - ), + color: s(`rgba(${hexToRgb(color)}, 0)`, color), }; return g; diff --git a/src/components/index.ts b/src/components/index.ts index cbec1fa..81e80f6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ +export * as Annotation from "./Annotation"; export * as Axis from "./Axis"; export * as AxisTick from "./AxisTick"; export * as ColorLegend from "./ColorLegend"; diff --git a/src/dims.ts b/src/dims.ts index 7d404e0..411b794 100644 --- a/src/dims.ts +++ b/src/dims.ts @@ -5,16 +5,6 @@ export type Margin = { left: number; }; -export type ResolvedDimensions = { - fullWidth: number; - width: number; - fullHeight: number; - height: number; - size: number; - margin: Margin; - BASE_MARGIN: number; -}; - export class Dimensions { public BASE_MARGIN = 16; @@ -35,16 +25,8 @@ export class Dimensions { this.height = height - this.top - this.bottom; } - resolve(): ResolvedDimensions { - return { - fullWidth: this.fullWidth, - width: this.width, - fullHeight: this.fullHeight, - height: this.height, - size: Math.min(this.width, this.height), - margin: this.margin, - BASE_MARGIN: this.BASE_MARGIN, - }; + get size(): number { + return Math.min(this.width, this.height); } get margin(): Margin { diff --git a/src/index.ts b/src/index.ts index 8b9971b..42148b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export type Info = { maxGroupLabelWidth: number; datumLabelDims: TextDims; datumValueDims: TextDims; + annotationDims: TextDims; }; export const info = (inputSteps: InputStep[], svg: Svg): Info => { @@ -42,6 +43,20 @@ export const info = (inputSteps: InputStep[], svg: Svg): Info => { const datumLabelDims = getTextsDims(datumLabels, svg, "datumLabel"); const datumValues = unique(inputSteps.flatMap(getDataValues)); const datumValueDims = getTextsDims(datumValues, svg, "datumValue"); + const annotationDims: TextDims = Object.fromEntries( + inputSteps.flatMap((d) => + (d.annotations ?? []).map((d) => [ + d.key, + svg.measureText(d.text ?? "", "annotationLabel", { + maxWidth: d.maxWidth ?? 100, + paddingLeft: 4, + paddingTop: 2, + paddingRight: 4, + paddingBottom: 2, + }), + ]) + ) + ); return { textTypeDims, @@ -49,6 +64,7 @@ export const info = (inputSteps: InputStep[], svg: Svg): Info => { maxGroupLabelWidth, datumLabelDims, datumValueDims, + annotationDims, }; }; @@ -98,7 +114,6 @@ const makeStory = ( let _t = 0; let _width = 0; let _height = 0; - let initialFontLoaded = false; let intsMap: Step.IntsMap | undefined; @@ -112,12 +127,8 @@ const makeStory = ( }; const fontLoadObserver = createFontLoadObserver(div, () => { - if (initialFontLoaded) { - storyInfo = info(inputSteps, svg); - prepareAndRender(); - } else { - initialFontLoaded = true; - } + storyInfo = info(inputSteps, svg); + prepareAndRender(); }); const resizeObserver = createResizeObserver(div, () => { diff --git a/src/types.ts b/src/types.ts index 8721370..1fd5d8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,9 @@ type BaseInputStep = { showValues?: boolean; showDatumLabels?: boolean; + /** Annotations. */ + annotations?: InputAnnotation[]; + /** Appearance. */ palette?: PaletteName; cartoonize?: boolean; @@ -173,6 +176,25 @@ export type InputDatumXY = BaseInputDatum & { y: number; }; +export type InputAnnotation = { + key: string; + type: "line"; + text?: string; + textAnchor?: Anchor; + fill?: string; + size?: number; + maxWidth?: number; +} & ( + | { + layout: "horizontal"; + y: number; + } + | { + layout: "vertical"; + x: number; + } +); + export type Anchor = "start" | "middle" | "end"; // Charts. @@ -207,7 +229,8 @@ export type TextType = | "axisTick" | "groupLabel" | "datumLabel" - | "datumValue"; + | "datumValue" + | "annotationLabel"; export type TextTypeDims = { [type in TextType]: { diff --git a/src/utils.ts b/src/utils.ts index 1bf808b..4f55f65 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { HALF_FONT_K } from "./charts/utils"; -import { Svg } from "./components"; +import { MeasureTextOptions, Svg } from "./components"; import { InputStep, State, TextType, TextTypeDims } from "./types"; export const unique = (array: T[]): T[] => { @@ -28,6 +28,7 @@ export const FONT_SIZE: Record = { groupLabel: 14, datumLabel: 11, datumValue: 11, + annotationLabel: 13, }; export const FONT_WEIGHT: Record = { @@ -40,6 +41,7 @@ export const FONT_WEIGHT: Record = { groupLabel: 400, datumLabel: 600, datumValue: 400, + annotationLabel: 400, }; export const getTextTypeDims = (svg: Svg): TextTypeDims => { @@ -56,10 +58,11 @@ export type TextDims = Record; export const getTextsDims = ( labels: (string | number)[], svg: Svg, - textType: TextType + textType: TextType, + options?: MeasureTextOptions ): TextDims => { const dims: TextDims = Object.fromEntries( - labels.map((d) => [d, svg.measureText(d, textType)]) + labels.map((d) => [d, svg.measureText(d, textType, options)]) ); return dims; };