Skip to content
Docxcelerate

← Registry

Weighted scatter

Body

What a scatter becomes when 'which of these matters' is the real question: plotting cost against usage says where things sit, and sizing the points by how many people they affect says which is worth the page. The size is an area and this keeps it one — a bubble drawn with its radius proportional to the figure shows twice the reading as four times the ink, which is the commonest way this chart lies, so the weight is passed to Word untouched. A point with no weight is not drawn, because a bubble of no size is a reading nobody took rather than a reading of nothing. Labels stay in the prose: a bubble chart labelled point by point is unreadable at a text column's width the moment two bubbles touch, so the chart shows the field and the sentence names the corner worth looking at.

npx dxcl add weighted-scatter
exports WeightedScatter · resolves to 1 nodes

Preview data

the JSON this preview was built with
{
  "scatter": {
    "xLabel": "Cost per visit",
    "yLabel": "Visits per week",
    "weightLabel": "members affected",
    "numberFormat": "#,##0",
    "points": [
      {
        "label": "Swimming",
        "x": 3,
        "y": 41,
        "weight": 420
      },
      {
        "label": "Gym",
        "x": 2,
        "y": 38,
        "weight": 610
      },
      {
        "label": "Classes",
        "x": 6,
        "y": 19,
        "weight": 240
      },
      {
        "label": "Squash",
        "x": 9,
        "y": 7,
        "weight": 85
      },
      {
        "label": "Climbing",
        "x": 12,
        "y": 5,
        "weight": 40
      }
    ]
  }
}

Rendered preview

the component rendered on its own
Rendered by Docxcelerate at build time Open ↗

Resolved nodes

what a renderer receives — 1 nodes
weighted-scatter section

The files

what installing writes; the selected file is the component
your-project/
nodes/weighted-scatter.node.tsx
import {
  Graph,
  Paragraph,
  Section,
  type Section as SectionComponent,
  useFormat,
  useState,
} from "docxcelerate/template";

/**
 * Two measures against each other, with a third as the weight — and the
 * sentence naming what sits in the corner.
 *
 * The chart a scatter turns into when "which of these matters" is the real
 * question. Plotting cost against usage tells you where things sit; sizing the
 * points by how many people it affects tells you which of them is worth the
 * page. A bubble is the only chart here that carries a third figure without
 * inventing a second axis to hang it on.
 *
 * **The size is an area, and the component keeps it one.** A bubble drawn with
 * its *radius* proportional to the figure shows twice the reading as four
 * times the ink, which is the single most common way this chart lies. Word
 * scales by area, and `weight` is passed through untouched so it stays that
 * way — do not pre-square it.
 *
 * Three more decisions come with it.
 *
 * **A point with no weight is not drawn.** A bubble of no size is a reading
 * nobody took, not a reading of nothing, and drawing it as a dot puts a point
 * on the chart that has no third figure while every other point does.
 *
 * **Labels are in the prose, not on the points.** A bubble chart labelled
 * point by point is unreadable at a text column's width the moment two bubbles
 * touch. The chart shows the shape of the field; the sentence names the corner
 * worth looking at, and the key names the series.
 *
 * **The corner is named, not left to the reader.** Which point is furthest out
 * is worked out here from the same rows the chart is drawn from, so the prose
 * and the plot cannot disagree about which one it is.
 *
 * Installed by `dxcl add weighted-scatter`.
 */

/** What this component reads. Add these fields to your document data type. */
export interface WeightedScatterData {
  scatter: {
    /** What the horizontal axis measures: `"Cost per visit"`. */
    xLabel: string;
    /** What the vertical axis measures: `"Visits per week"`. */
    yLabel: string;
    /** What the size of a point means: `"members affected"`. For the caption. */
    weightLabel: string;
    /**
     * One entry per point, in any order.
     *
     * A scatter has no sequence of its own, so nothing here is sorted for
     * drawing — only for finding the point the sentence names.
     */
    points: Array<{
      /** What this point is, for the sentence. Never drawn on the plot. */
      label: string;
      /** Where it sits along the horizontal axis. */
      x: number;
      /** Where it sits up the vertical one. */
      y: number;
      /**
       * How much it weighs, drawn as the point's area.
       *
       * Passed to Word untouched, which scales by area. Do not square it
       * first — a bubble whose radius is the figure shows twice the reading
       * as four times the ink.
       */
      weight: number;
    }>;
    /** How the axes print their figures, as an OOXML number format. */
    numberFormat?: string;
  };
}

export const WeightedScatter: SectionComponent = () => {
  const format = useFormat();
  const [scatter] = useState((data: WeightedScatterData) => data.scatter);

  // A point missing any of its three figures is not a point on this chart. A
  // weight of nothing especially: drawn, it is a dot with no third reading
  // sitting among bubbles that all have one.
  const points = (scatter.points ?? []).filter((point) =>
    Number.isFinite(point.x) && Number.isFinite(point.y) &&
    Number.isFinite(point.weight) && point.weight > 0
  );

  if (points.length === 0) {
    return (
      <Section id="weighted-scatter" title={`${scatter.yLabel} against ${scatter.xLabel}`}>
        <Paragraph id="weighted-scatter-none">
          We have nothing recorded to plot here yet.
        </Paragraph>
      </Section>
    );
  }

  const heaviest = [...points].sort((left, right) => right.weight - left.weight)[0];
  const dropped = (scatter.points ?? []).length - points.length;

  return (
    <Section id="weighted-scatter" title={`${scatter.yLabel} against ${scatter.xLabel}`}>
      <Paragraph id="weighted-scatter-summary">
        {format.number(points.length)} {format.plural(points.length, "point")} plotted, sized by{" "}
        {scatter.weightLabel}. The largest is {heaviest.label}, at{" "}
        {format.number(heaviest.weight)} {scatter.weightLabel}{" "}
        {scatter.xLabel.toLowerCase()} {format.number(heaviest.x)},{" "}
        {scatter.yLabel.toLowerCase()} {format.number(heaviest.y)}.
        {dropped > 0
          ? ` ${format.number(dropped)} ${format.plural(dropped, "entry", "entries")} had no ` +
            `${scatter.weightLabel} recorded and ${
              format.plural(dropped, "is", "are")
            } not shown.`
          : ""}
      </Paragraph>

      <Graph
        id="weighted-scatter-chart"
        graphType="bubble"
        // The categories are the x values — a bubble measures along both axes,
        // so these are numbers written as text rather than names.
        categoryAxisTitle={scatter.xLabel}
        valueAxisTitle={scatter.yLabel}
        numberFormat={scatter.numberFormat}
        // One series, so a key would name the chart's own title back at the
        // reader. What each bubble is belongs in the prose, not on the plot.
        legend="none"
        data={{
          categories: points.map((point) => String(point.x)),
          series: [
            {
              label: scatter.yLabel,
              values: points.map((point) => point.y),
              // Straight through. Word draws the area from this figure.
              sizes: points.map((point) => point.weight),
            },
          ],
        }}
        caption={`Each bubble is one entry; its size is ${scatter.weightLabel}.`}
      />
    </Section>
  );
};