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 }]);
};
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[] }]
}}
/>