Skip to content

ManyDots

ManyDots draws a collection of plain HTML points directly inside a Chart with a value scale and a cross scale. It uses one collection update rather than one slat and rendering computation per point. It remains an explicit alternative to Dot at any point count, with no automatic switch when data grows or shrinks.

ManyDots is part of the upcoming 2.0.3 release. The live example uses the current experimental source build. Until 2.0.3 is published on npm, follow installation from current source to try it in your app.

import { Chart, ManyDots } from "@bezda/rhp";

const samples = [
  { id: "a", x: 20, y: 35 },
  { id: "b", x: 60, y: 80 },
];

<Chart scale={[0, 100]} cross={[0, 100]} height={300} label="Measurements">
  <ManyDots
    rows={samples}
    at={row => row.x}
    cross={row => row.y}
    key={row => row.id}
    size="6px"
  />
</Chart>

Put ManyDots directly in the Chart, without a Plot or a slat wrapper. With the default horizontal orientation, at runs left to right and cross runs bottom to top. Vertical orientation swaps those axes. Both domains need finite, distinct endpoints, and points with nonfinite coordinates are omitted.

Colors and interaction

This example draws 1,200 points. Choose a shared color, four reusable category classes, or a unique color per point. Hover over a point to inspect it, or click/tap to select it, then try again after New data. The controls work with a keyboard; individual point navigation is not built into this collection.

Hover over a point, or tap/click one to select it.

No point selected.

import { createSignal } from "solid-js";
import { Chart, ManyDots } from "@bezda/rhp";

// A fixed seed gives the server and browser the same initial points.
function samples(seed) {
  let value = seed + 1;
  const next = () => ((value = (Math.imul(value, 1664525) + 1013904223) >>> 0) / 4294967296);
  return Array.from({ length: 1200 }, (_, id) => ({
    id,
    x: 4 + next() * 92,
    y: 4 + next() * 92,
    group: id % 4,
    color: `hsl(${(id * 137.508) % 360} 65% 48%)`,
  }));
}

const coordinates = (row) => `Point ${row.id + 1}: ${row.x.toFixed(1)}, ${row.y.toFixed(1)}`;

const css = `
  .manydots-demo .mode-category .group-0 { background-color: var(--docs-chart-s1); }
  .manydots-demo .mode-category .group-1 { background-color: var(--docs-chart-s2); }
  .manydots-demo .mode-category .group-2 { background-color: var(--docs-chart-s3); }
  .manydots-demo .mode-category .group-3 { background-color: var(--docs-chart-s4); }
  .manydots-demo .rhp-manydot:hover,
  .manydots-demo .rhp-manydot.selected {
    outline: 2px solid var(--docs-chart-ink);
    outline-offset: 2px;
  }
  .manydots-demo select {
    font: inherit;
    color: var(--docs-chart-ink);
    background: var(--docs-chart-surface);
    border: 1px solid var(--docs-chart-grid);
    border-radius: .35rem;
    padding: .2rem .4rem;
  }
  .manydots-demo .manydots-readout { min-height: 1.5em; margin: .5rem 0; }
`;

export default function Scatter() {
  const [rows, setRows] = createSignal(samples(0));
  const [mode, setMode] = createSignal("category");
  const [hovered, setHovered] = createSignal(null);
  const [selected, setSelected] = createSignal(null);
  let seed = 0;

  // Native events bubble from each plain HTML point to the collection.
  const rowAt = (event) => {
    const point = event.target.closest?.(".rhp-manydot");
    return point && event.currentTarget.contains(point)
      ? rows()[Number(point.dataset.rhpIndex)]
      : null;
  };
  const hover = (event) => {
    if (event.pointerType !== "touch") setHovered(rowAt(event));
  };
  const choose = (event) => {
    setHovered(null);
    setSelected(rowAt(event)?.id ?? null);
  };
  const refresh = () => {
    setHovered(null);
    setSelected(null);
    setRows(samples(++seed));
  };
  const chosen = () => rows().find((row) => row.id === selected());

  return (
    <div class="manydots-demo">
      <style>{css}</style>
      <div class="demo-controls">
        <label>
          Coloring{" "}
          <select onChange={(event) => setMode(event.currentTarget.value)}>
            <option value="shared" selected={mode() === "shared"}>Shared color</option>
            <option value="category" selected={mode() === "category"}>Category classes</option>
            <option value="unique" selected={mode() === "unique"}>Unique colors</option>
          </select>
        </label>
        <button onClick={refresh}>New data</button>
        <button onClick={() => { setHovered(null); setSelected(null); }}>Clear selection</button>
      </div>
      <Chart
        scale={[0, 100]}
        cross={[0, 100]}
        ticks={[0, 25, 50, 75, 100]}
        crossTicks={[0, 25, 50, 75, 100]}
        height={290}
        label="1,200 sample measurements, each with two coordinates from 4 to 96"
      >
        <ManyDots
          rows={rows()}
          at={(row) => row.x}
          cross={(row) => row.y}
          key={(row) => row.id}
          size="5px"
          class={`mode-${mode()}`}
          color={mode() === "unique" ? (row) => row.color : "series-1"}
          pointClass={(row) => `group-${row.group}${row.id === selected() ? " selected" : ""}`}
          onPointerMove={hover}
          onPointerLeave={() => setHovered(null)}
          onClick={choose}
        />
      </Chart>
      <p class="manydots-readout">
        {hovered() ? coordinates(hovered()) : "Hover over a point, or tap/click one to select it."}
      </p>
      <p class="manydots-readout" role="status">
        {chosen() ? `Selected ${coordinates(chosen()).toLowerCase()}.` : "No point selected."}
      </p>
    </div>
  );
}

The chart is rendered on the server before its island hydrates. Its fixed initial seed gives the server and browser the same rows and order. Native events bubble from the points to the collection, so the demo defines a few host handlers instead of a handler per point.

Props

at, cross, color, size, shape, pointClass, and pointStyle accept either a shared value or a (row, index) => value accessor. The index always refers to the original rows array, even when earlier rows have invalid coordinates and are omitted.

PropDefaultWhat it does
rowsRequiredA readonly array of data rows.
atRequiredA number or accessor for the value-axis coordinate.
crossRequiredA number or accessor for the cross-axis coordinate.
keySource row indexA (row, index) => identity accessor for keyed element reuse; duplicate keys among rendered points throw.
size"4px"Diameter as a CSS length or a number of pixels, shared or per point.
color"series-1"A literal CSS color or a theme key, shared or per point.
shapeCircleAn outline from shape(), shared or per point.
pointClassNoneSpace-separated application classes, shared or per point.
pointStyleNoneA Solid CSS properties object for point appearance, shared or per point.
class, classListNoneClasses on the collection’s host element.
styleNoneA Solid CSS properties object or CSS string on the host element.
refNoneA reference to the host element.
Event handlers and HTML attributesNoneNormal host attributes and handlers, including id, title, data-*, and aria-*.

Numeric sizes mean pixels in ManyDots. In Dot, a numeric size means a fraction of the slat’s band. Use a CSS length such as "6px" to give both components the same marker diameter. The collection does not accept point children, innerHTML, or textContent.

Styling many points

A shared size, color, and shape become concrete declarations in one stylesheet per collection. Category classes reuse application rules, while accessors can provide different colors, sizes, shapes, and styles for every point. Unique colors remain supported without creating one stylesheet rule per point.

const styledSamples = [
  { id: "a", x: 20, y: 35, color: "series-1", diameter: 6, selected: true },
  { id: "b", x: 60, y: 80, color: "#bd355a", diameter: 8, selected: false },
];

<ManyDots
  rows={styledSamples}
  at={row => row.x}
  cross={row => row.y}
  key={row => row.id}
  color={row => row.color}
  size={row => row.diameter}
  pointStyle={row => ({ opacity: row.selected ? 1 : 0.4 })}
/>

Application classes can style background color, border radius, opacity, outlines, filters, and transforms. Explicit per-point color and shape accessors use inline declarations, so they take precedence over ordinary class rules for those properties. Size, color, and shape accessors also take precedence over the same properties in pointStyle. The collection owns coordinates and centering, and protects padding, borders, and other box properties that would distort point geometry. Use size for diameter and outlines for marker edging; explicit pointStyle width and height can override a shared size. The narrow geometry guard does not apply the slat’s general text or font reset.

Fixed dimensions use half-size offsets for centering. If custom CSS animates those dimensions, animate the centering margins with them so the center stays stationary. Percentage and math dimensions need matching coordinate adjustments; intrinsic and CSS-variable sizes retain native size-relative translation. Use getBoundingClientRect() when positioning an overlay against the visible point box, including application transforms.

Native DOM and events

Each valid row has a .rhp-manydot element inside .rhp-manydots-points, under the .rhp-manydots host. Everything stays in light DOM, so document.querySelector, Testing Library, application classes, and page-level listeners can reach the points. Hover CSS and native click, touch, and pointer events remain available. Applications can build drag behavior with delegated pointer events; the collection does not provide a drag controller.

Use data-rhp-index to recover the original row in a host handler:

<ManyDots
  rows={samples}
  at={row => row.x}
  cross={row => row.y}
  onClick={event => {
    const point = event.target.closest?.(".rhp-manydot");
    if (point && event.currentTarget.contains(point)) {
      const row = samples[Number(point.dataset.rhpIndex)];
      console.log(row);
    }
  }}
/>

Native leaf listeners, attributes, and unmanaged styles survive ordinary keyed updates. Application edits to collection-managed declarations can be overwritten when that point’s managed styles change. Use the Chart’s label to name the figure, and provide a textual summary or data table when readers need access to individual measurements.

Updates and server rendering

Stable keys preserve point elements across replacement arrays, reordering, and removals. A dependency change still visits every row, but unchanged points receive no style writes. Data changes move points immediately, without Dot’s per-point transitions. JavaScript-driven Chart scale animation remains available through the shared frame.

Server rendering writes the complete collection, and hydration reuses its host, container, and points. Initial server points are adopted by order, then the client establishes keys for later updates. Keep the initial server and client row order aligned when row-specific DOM identity must survive hydration, because server keys are not serialized. Browsers without native CSS @scope use inherited-variable fallback styles and retain the collection’s appearance, without the measured shared-CSS speed benefit.

Choosing Dot or ManyDots

Keep Dot in a Plot when you need rich point content, custom slats, per-point handler props, or Plot’s selection, keyboard navigation, and readout APIs. Use ManyDots when plain points and collection-level interaction suit the chart. Native interaction stays available, but those Plot features are not built into ManyDots. There is no fixed count threshold or automatic conversion between the two components. Rendering cost also depends on marker size, clipping, filters, opacity, and application styling, so measure the chart you intend to ship.