Open Plant WSI
Interactive Region

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}
/>
드로잉 중 ESC를 누르면 현재 세션이 취소됩니다. stamp 툴은 클릭 즉시 완료됩니다.

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