Open Plant WSI
Interactive Region

Draw + Stamp ROI Tools

When `drawTool` is not `cursor`, `DrawLayer` captures pointer events and returns closed polygon coordinates through `onDrawComplete`. Brush also emits completion payload (intent: "brush"). Stamp tools place physical-size ROI in one click (2mm² square/circle, 0.2mm² HPF circle). The fixed-pixel stamp-rectangle-4096px emits patch intent and can be handled with onPatchComplete.

1. Toolbar state

const [drawTool, setDrawTool] = useState("cursor");

<button onClick={() => setDrawTool("freehand")}>Freehand</button>
<button onClick={() => setDrawTool("rectangle")}>Rectangle</button>
<button onClick={() => setDrawTool("circular")}>Circular</button>
<button onClick={() => setDrawTool("stamp-rectangle")}>Stamp □</button>
<button onClick={() => setDrawTool("stamp-circle")}>Stamp ○</button>
<button onClick={() => setDrawTool("stamp-rectangle-4096px")}>Stamp 4096px</button>
<button onClick={() => setCircleAreaMm2(0.2)}>HPF 0.2mm²</button>
<button onClick={() => setDrawTool("cursor")}>Cursor</button>

<WsiViewerCanvas
  source={source}
  drawTool={drawTool}
  interactionLock={drawTool !== "cursor"}
/>

2. Completion payload

const onDrawComplete = (result) => {
  console.log(result.tool);        // freehand | rectangle | circular | brush | stamp-...
  console.log(result.intent);      // "roi" | "patch" | "brush"
  console.log(result.coordinates); // closed ring
  console.log(result.bbox);        // [minX, minY, maxX, maxY]
  console.log(result.areaPx);      // polygon area
};

const onPatchComplete = (patch) => {
  // patch.tool === "stamp-rectangle-4096px"
  setPatchRegions((prev) => [...prev, { id: Date.now(), coordinates: patch.coordinates }]);
};
Press Esc to cancel an active drawing gesture. Stamp tools complete on click.

3. Brush UX (screen-fixed radius + tap-to-select)

<WsiViewerCanvas
  source={source}
  drawTool="brush"
  brushOptions={{
    radius: 24,           // HTML/CSS px (screen-fixed)
    edgeDetail: 1.5,
    edgeSmoothing: 2,
    clickSelectRoi: true, // tap on ROI in brush mode selects ROI first
  }}
  onDrawComplete={(result) => {
    // brush stroke completion -> result.intent === "brush"
  }}
  onRegionClick={(event) => {
    // emitted when clickSelectRoi=true and tap hits ROI contour/label
  }}
/>

With clickSelectRoi=true, a brush tap on ROI selects/toggles active ROI first. A tap outside ROI behaves as a normal brush stroke.

4. Stamp size props (outside control)

const [rectAreaMm2, setRectAreaMm2] = useState(2);
const [circleAreaMm2, setCircleAreaMm2] = useState(2);
const [rectPixelSize, setRectPixelSize] = useState(4096);

<WsiViewerCanvas
  source={source}
  drawTool={drawTool}
  stampOptions={{
    rectangleAreaMm2: rectAreaMm2,
    circleAreaMm2: circleAreaMm2,
    rectanglePixelSize: rectPixelSize,
  }}
/>

mpp means microns per pixel at native level (maxTierZoom). Stamp area in mm² is converted using mpp plus current zoom, so physical size remains consistent while zooming.

5. Persist regions and labels

const [regions, setRegions] = useState([]);

const handleDrawComplete = (result) => {
  setRegions((prev) => [
    ...prev,
    {
      id: `${Date.now()}`,
      label: "Tumor Core",
      coordinates: result.coordinates,
    },
  ]);
  setDrawTool("cursor");
};

<WsiViewerCanvas
  source={source}
  drawTool={drawTool}
  roiRegions={regions}
  onDrawComplete={handleDrawComplete}
/>

Labels are painted last and anchored to the top edge of each region.

6. External interaction + style control

<WsiViewerCanvas
  source={source}
  roiRegions={regions}
  activeRegionId={selectedRoiId} // controlled mode
  onActiveRegionChange={setSelectedRoiId}
  drawFillColor="transparent"
  patchRegions={patches}
  patchStrokeStyle={{ color: "#8ad8ff", width: 2, lineDash: [10, 8] }}
  regionStrokeStyle={{
    color: "#ffd166",
    width: 2.5,
  }}
  regionStrokeHoverStyle={{
    color: "#ff2f2f",
    width: 3,
  }}
  regionStrokeActiveStyle={{
    color: "#ff2f2f",
    width: 3,
    shadowColor: "rgba(255, 47, 47, 0.95)",
    shadowBlur: 12,
    shadowOffsetX: 0,
    shadowOffsetY: 0,
  }}
  resolveRegionStrokeStyle={(ctx) => {
    if (ctx.state === "hover") return { color: "#ff2f2f", width: 3 };
    if (ctx.state === "active") return { color: "#ff2f2f", width: 3.2, shadowBlur: 12 };
  }}
  resolveRegionLabelStyle={({ zoom }) => ({
    offsetY: zoom > 4 ? -20 : -10,
  })}
  autoLiftRegionLabelAtMaxZoom
  drawAreaTooltip={{
    enabled: true,
    format: (areaMm2) => `${areaMm2.toFixed(3)} mm²`,
    cursorOffset: { x: 16, y: -24 },
  }}
  overlayShapes={[
    {
      id: "weak-positive-area",
      coordinates: [
        [[1000,1000],[3000,1000],[3000,3000],[1000,3000]],
        [[3400,1200],[4300,1200],[4300,2300],[3400,2300]],
      ],
      closed: true,
      stroke: { color: "#CB59FF", width: 3, lineDash: [5, 5] },
      invertedFill: { fillColor: "rgba(0, 0, 0, 0.15)" },
      visible: true,
    },
  ]}
  onPointerWorldMove={(event) => {
    // event.coordinate -> [x, y] | null
  }}
  regionLabelStyle={{
    fontFamily: "IBM Plex Mono, monospace",
    fontSize: 12,
    textColor: "#fff4cc",
    backgroundColor: "rgba(8, 14, 22, 0.9)",
    borderColor: "#ffd166",
    borderWidth: 1,
    paddingX: 8,
    paddingY: 4,
    offsetY: 12,
    borderRadius: 4,
  }}
  onRegionHover={(event) => {
    // event.regionId, event.regionIndex, event.coordinate
  }}
  onRegionClick={(event) => {
    // event.regionId, event.region, event.coordinate
  }}
  onPatchComplete={(patch) => {
    // patch-only completion callback
  }}
  customLayers={[
    {
      id: "patch-label-layer",
      render: ({ worldToScreen }) => {
        // host-owned overlay with world/screen transform
      },
    },
  ]}
/>

Region hit-test in cursor mode targets contour + label only (region interior fill is excluded). If activeRegionId is omitted, the viewer falls back to uncontrolled active state.

7. Render cells only inside drawn regions

<WsiViewerCanvas
  source={source}
  pointData={pointData}
  pointPalette={pointPalette}
  roiRegions={regions}
  clipPointsToRois
  clipMode="worker"
  onClipStats={(stats) => {
    console.log(stats.mode, stats.durationMs, stats.outputCount);
  }}
/>

clipMode controls ROI filtering runtime: sync (main thread), worker (recommended), and hybrid-webgpu (experimental).

8. ROI term-count stats API

import { computeRoiPointGroups } from "open-plant";

const stats = computeRoiPointGroups(pointData, regions, {
  paletteIndexToTermId: ["bg", "negative", "positive"],
});

<WsiViewerCanvas
  source={source}
  pointData={pointData}
  roiRegions={regions}
  onRoiPointGroups={(result) => {
    // result.groups -> [{ regionId, totalCount, termCounts[] }]
  }}
/>