Draw + Stamp ROI 툴
`drawTool`이 `cursor`가 아닌 동안 `DrawLayer`가 활성화되어
pan/zoom과 충돌 없이 ROI를 생성합니다. 결과는 닫힌 polygon 좌표로
반환됩니다. brush도 완료 시점에 intent: "brush" payload를 반환합니다.
stamp 툴은 클릭 한 번으로 물리 크기 ROI(2mm² 사각형/원, 0.2mm² HPF 원)를
생성합니다. 고정 픽셀 사각형(stamp-rectangle-4096px)은
patch intent로 완료되며 onPatchComplete에서 별도 처리할 수 있습니다.
1. 툴바와 draw 상태
import { useState } from "react";
import { WsiViewerCanvas } from "open-plant";
const [drawTool, setDrawTool] = useState("cursor");
<div className="toolbar">
<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>
</div>
<WsiViewerCanvas
source={source}
drawTool={drawTool}
interactionLock={drawTool !== "cursor"}
/>
2. Draw 완료 결과
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 }]);
};
<WsiViewerCanvas
source={source}
drawTool={drawTool}
onDrawComplete={onDrawComplete}
onPatchComplete={onPatchComplete}
/>
3. Brush UX (화면 고정 반경 + 탭 선택)
<WsiViewerCanvas
source={source}
drawTool="brush"
brushOptions={{
radius: 24, // HTML/CSS px 기준 (화면에서 줌 불변)
edgeDetail: 1.5,
edgeSmoothing: 2,
clickSelectRoi: true, // brush 모드 탭 시 ROI를 먼저 선택
}}
onDrawComplete={(result) => {
// brush 완료 -> result.intent === "brush"
}}
onRegionClick={(event) => {
// clickSelectRoi=true + ROI contour/label 탭 시 이벤트
}}
/>
clickSelectRoi=true이면 brush 탭에서 ROI 선택/토글을 우선 수행합니다.
ROI 외부 탭은 일반 brush stroke로 처리됩니다.
4. Stamp 크기 prop 제어
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는 네이티브 레벨(maxTierZoom)에서의 픽셀당 마이크론(μm/px)입니다.
stamp 면적(mm²)은 mpp와 현재 줌으로 환산되어 줌이 바뀌어도 물리 크기가 유지됩니다.
5. ROI 영속화와 라벨 표시
const [regions, setRegions] = useState([]);
const [labelInput, setLabelInput] = useState("ROI");
const handleDrawComplete = (result) => {
setRegions((prev) => [
...prev,
{
id: `${Date.now()}`,
label: labelInput,
coordinates: result.coordinates,
},
]);
setDrawTool("cursor");
};
<WsiViewerCanvas
source={source}
drawTool={drawTool}
roiRegions={regions}
patchRegions={patches}
patchStrokeStyle={{ color: "#8ad8ff", width: 2, lineDash: [10, 8] }}
onDrawComplete={handleDrawComplete}
/>
라벨은 각 region의 최상단(anchor) 기준으로 마지막에 그려지므로, 윤곽선/채움 위에 항상 보입니다.
6. 인터랙션/스타일 외부 제어
<WsiViewerCanvas
source={source}
roiRegions={regions}
activeRegionId={selectedRoiId} // controlled 모드
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 전용 완료 이벤트
}}
customLayers={[
{
id: "patch-label-layer",
render: ({ worldToScreen }) => {
// host가 직접 제어하는 React 오버레이
},
},
]}
/>
cursor 모드 region hit-test는 contour + 라벨 영역만 대상으로 하며 내부 fill은 제외됩니다.
activeRegionId를 생략하면 기존처럼 uncontrolled active 상태로 동작합니다.
7. Draw 영역에만 셀 렌더
<WsiViewerCanvas
source={source}
pointData={pointData}
pointPalette={pointPalette}
roiRegions={regions}
clipPointsToRois
clipMode="worker"
onClipStats={(stats) => {
console.log(stats.mode, stats.durationMs, stats.outputCount);
}}
/>
`clipPointsToRois`를 켜면 포인트 버퍼가 ROI polygon 내부 포인트만 남도록 필터링되어 렌더됩니다.
clipMode는 ROI 필터 실행 경로를 선택합니다:
sync(메인 스레드), worker(권장), hybrid-webgpu(실험적).
8. ROI term count 통계 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[] }]
}}
/>