Dalle callback al rendering: costruire un tool TPA in CornerstoneTools
In questo articolo vedremo come funziona un tool custom sviluppato con CornerstoneTools, esplorando i meccanismi interni che regolano callback, eventi e gestione dello stato. Per rendere il tutto più concreto, useremo come caso pratico il TPA (Tibial Plateau Angle), un esempio particolarmente interessante perché richiede un flusso di interazione multi-step e una logica più complessa rispetto agli strumenti standard. Attraverso questo esempio, capiremo come progettare e controllare ogni fase del ciclo di vita di un tool, dal primo input dell’utente fino al rendering finale.
1. Dichiarare un Custom Tool
Un custom tool è una classe TypeScript (o JavaScript) che estende BaseAnnotationTool — o una delle sue sottoclassi come BaseTool per strumenti senza annotation persistente. Il minimo indispensabile per dichiararlo è il costruttore con defaultProps e i metodi che vuoi sovrascrivere.
import cornerstoneTools from 'cornerstone-tools';
import cornerstone from 'cornerstone-core';
const BaseAnnotationTool = cornerstoneTools.importInternal('base/BaseAnnotationTool');
const { lengthCursor } = cornerstoneTools.importInternal('tools/cursors');
export default class MyCustomTool extends BaseAnnotationTool {
constructor(props: any = {}) {
const defaultProps = {
name: 'MyCustomTool', // nome univoco — usato come chiave nel toolState
supportedInteractionTypes: ['Mouse', 'Touch'],
configuration: {
drawHandles: true,
drawHandlesOnHover: false,
hideHandlesIfMoving: false,
renderDashed: false,
},
svgCursor: lengthCursor, // cursore SVG mostrato quando il tool è attivo
};
super(props, defaultProps);
}
// Obbligatorio: restituisce l'oggetto dati iniziale della misura
createNewMeasurement(eventData: any) {
const { x, y } = eventData.currentPoints.image;
return {
visible: true,
active: true,
invalidated: true,
handles: {
start: { x, y, highlight: true, active: false },
end: { x, y, highlight: true, active: true },
textBox: {
active: false, hasMoved: false,
movesIndependently: false, drawnIndependently: true,
allowedOutsideImage: true, hasBoundingBox: true,
},
},
cachedStats: {},
};
}
// Obbligatorio: hit-testing per click e hover
pointNearTool(element: HTMLElement, data: any, coords: any, interactionType: string) {
if (!data?.handles?.start || !data?.handles?.end) return false;
// … logica di prossimità
return false;
}
// Obbligatorio: disegna sul canvas
renderToolData(evt: any) {
const { element, canvasContext } = evt.detail;
const toolData = cornerstoneTools.getToolState(element, this.name);
if (!toolData?.data?.length) return;
// … draw
}
// Opzionale: calcola statistiche solo quando invalidated = true
calculateCachedStats(data: any, viewport: any, image: any) {
data.cachedStats = { /* … */ };
return data.cachedStats;
}
}
Il name in defaultProps è la chiave con cui il tool viene registrato nel toolState e referenziato in tutte le API (addToolState, getToolState, setToolActive). Deve essere univoco all’interno dell’applicazione.
2. Cosa sono le Callback
In CornerstoneTools, una callback è una funzione che viene invocata automaticamente dal framework in risposta a un evento specifico — interazione utente, cambio di stato dello strumento, o ciclo di rendering.
Non stai ascoltando eventi DOM grezzi. CornerstoneTools li intercetta, li arricchisce con contesto medico (coordinate immagine, pixel spacing, viewport state) e li consegna alle tue callback già “digeriti”.
// Esempio minimale: intercettare la creazione di una misura
cornerstoneTools.setToolActive('Length', { mouseButtonMask: 1 });
element.addEventListener('cornerstonetoolsmeasurementcompleted', (e) => {
const { measurementData } = e.detail;
console.log('Nuova misura:', measurementData.length, 'mm');
});
Le callback hanno due forme principali:
Event listener sul DOM element di Cornerstone — pattern standard browser
Metodi override sullo strumento — ad es. createNewMeasurement, renderToolData, pointNearTool
3. Il Flusso di Evento di Cornerstone
Capire come un evento nasce, viene trasformato e consegnato è fondamentale per debuggare comportamenti inaspettati.
Input Utente (browser)
│
▼
┌───────────────────┐
│ DOM Event Raw │ mousedown / mousemove / click / touch…
└────────┬──────────┘
│
▼
┌───────────────────────────────┐
│ CornerstoneTools InputSource │ Normalizza input (mouse, touch, pointer)
│ (MouseEventHandlers etc.) │
└────────┬──────────────────────┘
│
▼
┌───────────────────────────────────┐
│ Store & ToolState │ Recupera lo strumento attivo
│ getToolState(element, toolName) │ e i dati già esistenti sull’immagine
└────────┬──────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Tool Method Dispatch │
│ pointNearTool? → handleSelectedCallback │
│ altrimenti → addNewMeasurement │
└────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ cornerstone.triggerEvent(element, …) │ Emette evento Cornerstone
│ e.g. MEASUREMENT_ADDED │ arricchito con eventData
└────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ cornerstone.updateImage(element) │ Invalida il canvas
│ → renderToolData() │ e ri-renderizza gli overlay
└──────────────────────────────────────────┘
Propagazione degli eventi
CornerstoneTools usa cornerstone.triggerEvent, che wrappa CustomEvent con bubbles: false di default. Gli eventi non propagano automaticamente al DOM padre — devi ascoltarli sull’element Cornerstone specifico.
4. EventData — Cosa ricevi ad ogni callback
Ogni evento Cornerstone ha un campo event.detail con struttura variabile per tipo. Ecco i campi più comuni.
// Struttura base di eventData
element.addEventListener('cornerstonetoolsmeasurementadded', (event) => {
const {
toolName, // 'Length', 'Angle', 'EllipticalRoi'…
toolType, // alias di toolName (deprecato ma presente)
element, // HTMLElement del viewport
measurementData, // oggetto dati specifico dello strumento
image, // oggetto immagine Cornerstone corrente
} = event.detail;
});
measurementData — struttura tipica
{
// Identificazione
uuid: 'abc-123-…', // ID univoco della misura
toolName: 'Length',
// Stato interazione
active: true, // true mentre l'utente sta disegnando
visible: true,
invalidated: true, // true = cachedStats da ricalcolare
// Handles — i punti di controllo drag-and-drop
handles: {
start: { x: 120, y: 45, highlight: false, active: false },
end: { x: 200, y: 90, highlight: false, active: false },
textBox: {
active: false,
hasMoved: false,
movesIndependently: false,
drawnIndependently: true,
allowedOutsideImage: true,
hasBoundingBox: true,
}
},
// Risultati calcolati (popolati da calculateCachedStats)
length: 42.3, // mm, se pixelSpacing disponibile
cachedStats: { … },
}
Coordinate: pixel vs immagine vs canvas
Questo è uno dei punti più critici. CornerstoneTools lavora sempre in coordinate immagine internamente.
// Coordinate canvas (pixel fisici del canvas HTML)
const canvasPoint = { x: event.clientX, y: event.clientY };
// → coordinate immagine (indipendenti da zoom/pan)
const imagePoint = cornerstone.canvasToPixel(element, canvasPoint);
// → coordinate canvas (per il rendering)
const backToCanvas = cornerstone.pixelToCanvas(element, imagePoint);
Gli handles in measurementData sono sempre in coordinate immagine. Il rendering li converte al momento del draw.
5. Tipologie di Callback
4.1 Callback del Ciclo di Vita dello Strumento
Gestiscono la nascita, modifica e morte di una misura.
Metodo / Evento | Quando viene chiamato |
|---|---|
createNewMeasurement(eventData) | Prima creazione — mousedown su canvas vuoto |
addNewMeasurement(evt, interactionType) | Orchestratore: chiama createNewMeasurement e gestisce il loop di drag |
MEASUREMENT_ADDED | Dopo che la misura è stata aggiunta al toolState |
MEASUREMENT_MODIFIED | Dopo ogni modifica (drag handle, move) |
MEASUREMENT_COMPLETED | Quando l’utente rilascia il mouse al termine della creazione |
MEASUREMENT_REMOVED | Dopo rimozione dal toolState |
// Override createNewMeasurement nel tuo strumento custom
createNewMeasurement(eventData) {
const { currentPoints, image } = eventData;
return {
visible: true,
active: true,
invalidated: true,
handles: {
start: {
x: currentPoints.image.x,
y: currentPoints.image.y,
highlight: true,
active: false,
},
end: {
x: currentPoints.image.x,
y: currentPoints.image.y,
highlight: true,
active: true, // end è attivo → segue il mouse
},
textBox: { … }
}
};
}
4.2 Callback di Input (Mouse & Touch)
Queste callback sono metodi dello strumento che puoi sovrascrivere per personalizzare il comportamento.
Metodo | Trigger |
|---|---|
mouseDownCallback(evt) | mousedown sull’element |
mouseDownActivateCallback(evt) | mousedown quando lo strumento è Active e il punto NON è vicino a una misura esistente |
mouseMoveCallback(evt) | mousemove (strumento in Passive o Active) |
mouseDragCallback(evt) | mousemove durante drag (dopo mousedown) |
mouseUpCallback(evt) | mouseup |
mouseClickCallback(evt) | click singolo |
mouseDoubleClickCallback(evt) | doppio click |
// eventData per gli eventi mouse
{
event, // DOM Event originale
which: 1, // bottone mouse (1=left, 2=middle, 3=right)
element, // HTMLElement
image, // immagine Cornerstone
currentPoints: {
page: { x, y }, // coordinate pagina
image: { x, y }, // coordinate immagine ← usi queste
canvas: { x, y }, // coordinate canvas
client: { x, y },
},
startPoints: { … }, // punto di inizio drag
deltaPoints: { … }, // delta rispetto al frame precedente
viewport, // stato viewport corrente
type: 'mousedrag',
}
4.3 Callback di Selezione e Interazione
Il meccanismo di selezione è il cuore del sistema di modifica.
Metodo | Ruolo |
|---|---|
pointNearTool(element, data, coords, interactionType) | Determina se le coordinate sono “vicine” alla misura |
handleSelectedCallback(evt, toolData, handle, interactionType) | Chiamata quando l’utente clicca su un handle specifico |
toolSelectedCallback(evt, toolData, interactionType) | Chiamata quando clicca sul corpo dello strumento (non su un handle) |
activeCallback(element, data) | Lo strumento o la misura diventa attiva |
// pointNearTool — esempio per uno strumento linea
pointNearTool(element, data, coords, interactionType) {
const hasStartAndEndHandles =
data.handles && data.handles.start && data.handles.end;
if (!hasStartAndEndHandles) return false;
if (interactionType === 'mouse') {
return (
lineSegDistance(element, data.handles.start, data.handles.end, coords)
< 25 // pixel di tolleranza
);
}
// Per touch, tolleranza maggiore
return lineSegDistance(element, data.handles.start, data.handles.end, coords) < 40;
}
4.4 Callback di Rendering
Metodo | Ruolo |
|---|---|
renderToolData(evt) | Disegna TUTTE le misure di questo strumento sul canvas |
calculateCachedStats(data, viewport, image) | Calcola statistiche (lunghezza, area, mean HU…) — chiamato solo se data.invalidated === true |
renderToolData(evt) {
const { element, canvasContext, image } = evt.detail;
const toolData = cornerstoneTools.getToolState(element, this.name);
if (!toolData || !toolData.data || !toolData.data.length) return;
for (const data of toolData.data) {
if (!data.visible) continue;
// Ricalcola solo se necessario
if (data.invalidated) {
this.calculateCachedStats(data, evt.detail.viewport, image);
data.invalidated = false;
}
// Converti handles da image → canvas per il draw
const startCanvas = cornerstone.pixelToCanvas(element, data.handles.start);
const endCanvas = cornerstone.pixelToCanvas(element, data.handles.end);
canvasContext.beginPath();
canvasContext.moveTo(startCanvas.x, startCanvas.y);
canvasContext.lineTo(endCanvas.x, endCanvas.y);
canvasContext.strokeStyle = data.active ? 'yellow' : 'white';
canvasContext.lineWidth = 2;
canvasContext.stroke();
}
}
6. Caso pratico: TPAAnnotationTool
Il Tibial Plateau Angle (TPA) è una misura veterinaria che calcola l’angolo di inclinazione del plateau tibiale rispetto all’asse funzionale della tibia. È un esempio eccellente perché richiede un flusso di disegno multi-step che non si adatta al pattern standard di CornerstoneTools — e mostra come gestire stati complessi sovrascrivendo le callback di input.
Il problema del multi-step
Un tool standard (es. Length) ha un flusso semplice: mousedown → drag → mouseup. Il TPA richiede tre fasi distinte:
- FTA (Functional Tibial Axis) — linea dall’asse tibiale al plafond della caviglia
- MTP (Medial Tibial Plateau Line) — linea lungo il pendio del plateau mediale
- REF (Reference Line) — calcolata automaticamente: perpendicolare alla FTA, ancorata all’intersezione FTA∩MTP (rette infinite)
Per gestire questo flusso, l’implementazione usa una state machine esplicita:
enum TPAState {
IDLE = 0,
FUNCTIONAL_AXIS_START = 1, // dragging FTA
FUNCTIONAL_AXIS_END = 2, // FTA completa, attesa click MTP
MEDIAL_PLATEAU_START = 3, // dragging MTP
MEDIAL_PLATEAU_END = 4,
COMPLETE = 5
}
createNewMeasurement — struttura dati multi-handle
Il TPA ha sei handles (tre linee × due estremi) più quattro textbox, tutti inizializzati al punto di click. Il campo measurementState è salvato nell’oggetto dati — non solo nella classe — in modo che renderToolData possa sapere cosa disegnare anche su misure caricate da database:
createNewMeasurement(eventData: EventData) {
const { x, y } = eventData.currentPoints!.image!;
return {
visible: true,
active: true,
invalidated: true,
handles: {
// Passo 1: FTA
ftaStart: { x, y, highlight: true, active: false },
ftaEnd: { x, y, highlight: true, active: true }, // segue il mouse
// Passo 2: MTP
mtpStart: { x: 0, y: 0, highlight: true, active: false },
mtpEnd: { x: 0, y: 0, highlight: true, active: false },
// Passo 3: Reference Line (calcolata)
refStart: { x: 0, y: 0, highlight: false, active: false },
refEnd: { x: 0, y: 0, highlight: false, active: false },
intersectionPoint: { x: 0, y: 0 }, // FTA∩MTP
// TextBox per ogni linea + angolo finale
ftaTextBox: { … }, mtpTextBox: { … },
refTextBox: { … }, tpaTextBox: { … }
},
cachedStats: { tpaAngle: '0', ftaLength: '0', mtpLength: '0' },
measurementState: TPAState.IDLE // ← salvato nei dati, non solo nella classe
};
}
addNewMeasurement — orchestrazione multi-step
Invece di creare semplicemente una misura e fare drag, addNewMeasurement gestisce le transizioni tra stati. Viene chiamato ad ogni mousedown quando il punto non è vicino a una misura esistente:
addNewMeasurement(evt: MeasurementMouseEvent) {
const eventData = evt.detail;
const { element } = eventData;
switch (this.currentState) {
case TPAState.IDLE:
// Primo click: crea l'annotazione e inizia FTA
this.currentAnnotation = this.createNewMeasurement(eventData);
this.currentState = TPAState.FUNCTIONAL_AXIS_START;
this.currentAnnotation.measurementState = this.currentState;
cornerstoneTools.addToolState(element, this.name, this.currentAnnotation);
break;
case TPAState.FUNCTIONAL_AXIS_END:
// Secondo click: inizia MTP dal punto corrente
const { x, y } = eventData.currentPoints.image;
this.currentAnnotation.handles.mtpStart = { x, y, highlight: true, active: false };
this.currentAnnotation.handles.mtpEnd = { x, y, highlight: true, active: true };
this.currentState = TPAState.MEDIAL_PLATEAU_START;
this.currentAnnotation.measurementState = this.currentState;
cornerstone.updateImage(element);
break;
}
}
preMouseDownCallback vs mouseUpCallback — doppia strada
Il tool supporta due modalità di disegno: click-move-click e click-drag-release. La stessa transizione di stato deve avvenire in entrambi i casi, ma attraverso callback diverse.
preMouseDownCallback intercetta il click prima che addNewMeasurement venga invocato — permettendo di completare il passo corrente prima che ne inizi uno nuovo. mouseUpCallback completa il passo quando l’utente rilascia il mouse dopo un drag:
// Percorso drag: mousedown → mouseDragCallback → mouseUpCallback
mouseUpCallback(evt) {
if (!this.isDragging) return;
this.isDragging = false;
switch (this.currentState) {
case TPAState.FUNCTIONAL_AXIS_START:
// FTA completata via drag → attendi click per MTP
this.currentState = TPAState.FUNCTIONAL_AXIS_END;
this.currentAnnotation.handles.ftaEnd.active = false;
this.updateCachedStats(eventData.image, element, this.currentAnnotation);
break;
case TPAState.MEDIAL_PLATEAU_START:
// MTP completata via drag → calcola REF e angolo
this.currentState = TPAState.COMPLETE;
this.computeReferenceLineAndAngle(this.currentAnnotation);
this.currentAnnotation = null;
this.currentState = TPAState.IDLE;
break;
}
cornerstone.updateImage(element);
}
// Percorso click: mousedown → preMouseDownCallback
preMouseDownCallback(evt) {
switch (this.currentState) {
case TPAState.FUNCTIONAL_AXIS_START:
// Click senza drag: FTA disegnata via mousemove
this.currentState = TPAState.FUNCTIONAL_AXIS_END;
this.currentAnnotation.handles.ftaEnd.active = false;
break;
case TPAState.MEDIAL_PLATEAU_START:
// Click finale: completa MTP e calcola tutto
this.computeReferenceLineAndAngle(this.currentAnnotation);
this.currentAnnotation = null;
this.currentState = TPAState.IDLE;
break;
}
cornerstone.updateImage(element);
}
mouseMoveCallback e mouseDragCallback — preview in tempo reale
Durante il disegno, entrambe le callback aggiornano il handle “end” attivo in base alla posizione corrente. La differenza è che mouseDragCallback imposta isDragging = true per distinguere la modalità drag dalla modalità click-move-click — impedendo a preMouseDownCallback di intervenire durante un drag in corso:
mouseMoveCallback(evt) {
if (!this.currentAnnotation || this.isDragging) return;
const { x, y } = evt.detail.currentPoints.image;
switch (this.currentState) {
case TPAState.FUNCTIONAL_AXIS_START:
this.currentAnnotation.handles.ftaEnd.x = x;
this.currentAnnotation.handles.ftaEnd.y = y;
break;
case TPAState.MEDIAL_PLATEAU_START:
this.currentAnnotation.handles.mtpEnd.x = x;
this.currentAnnotation.handles.mtpEnd.y = y;
break;
}
this.currentAnnotation.invalidated = true;
cornerstone.updateImage(evt.detail.element);
}
computeReferenceLineAndAngle — la geometria del TPA
Questo è il cuore matematico del tool. La linea di riferimento è perpendicolare alla FTA e passa per il punto di intersezione tra le rette infinite FTA e MTP — non i segmenti disegnati:
computeReferenceLineAndAngle(data: any): void {
const { handles } = data;
// Direzione FTA normalizzata
const ftaDx = handles.ftaEnd.x - handles.ftaStart.x;
const ftaDy = handles.ftaEnd.y - handles.ftaStart.y;
const ftaMag = Math.sqrt(ftaDx**2 + ftaDy**2);
if (ftaMag === 0) return;
const ftaNx = ftaDx / ftaMag;
const ftaNy = ftaDy / ftaMag;
// Perpendicolare alla FTA
const perpNx = -ftaNy;
const perpNy = ftaNx;
// Intersezione FTA∩MTP (rette infinite, parametric intersection)
const intersection = this.computeLineLineIntersection(
handles.ftaStart, { x: ftaDx, y: ftaDy },
handles.mtpStart, { x: mtpDx, y: mtpDy }
);
// Anchor = intersezione, fallback = mtpStart se parallele
const anchor = intersection ?? handles.mtpStart;
handles.intersectionPoint = anchor;
// Reference Line: ±70px attorno all'anchor lungo la perpendicolare
handles.refStart = { x: anchor.x - perpNx * 70, y: anchor.y - perpNy * 70 };
handles.refEnd = { x: anchor.x + perpNx * 70, y: anchor.y + perpNy * 70 };
// TPA = angolo tra direzione MTP e Reference Line
const dot = (mtpDx/mtpMag) * perpNx + (mtpDy/mtpMag) * perpNy;
const angleDeg = Math.acos(Math.abs(dot)) * (180 / Math.PI);
data.cachedStats.tpaAngle = angleDeg.toFixed(1);
// Salva i vettori per il draw dell'arco
data.cachedStats.mtpNx = (mtpDx/mtpMag).toFixed(6);
data.cachedStats.mtpNy = (mtpDy/mtpMag).toFixed(6);
data.cachedStats.perpNx = perpNx.toFixed(6);
data.cachedStats.perpNy = perpNy.toFixed(6);
}
Il punto critico: l’intersezione viene calcolata sulle rette infinite, non sui segmenti disegnati. Questo è il comportamento clinicamente corretto — il plateau tibiale e l’asse funzionale si prolungano geometricamente oltre i punti segnati dall’utente.
renderToolData — rendering progressivo per stato
Il renderer usa measurementState per decidere cosa disegnare. Questo permette di visualizzare la misura parziale durante la creazione e gestisce correttamente il caricamento da storage:
renderToolData(evt) {
toolData.data.forEach((data: any) => {
const { handles, cachedStats, measurementState } = data;
const color = toolColors.getColorIfActive(data);
// Linea FTA — blu, visibile dal primo passo
if (measurementState >= TPAState.FUNCTIONAL_AXIS_START) {
drawLine(ctx, element, handles.ftaStart, handles.ftaEnd,
{ color: 'rgb(80, 200, 255)', lineWidth: 2 });
drawHandles(ctx, eventData, [handles.ftaStart, handles.ftaEnd],
{ color: 'rgb(80, 200, 255)' });
// Label "FTA" — solo quando completa
if (measurementState >= TPAState.FUNCTIONAL_AXIS_END) {
drawLinkedTextBox(ctx, element, handles.ftaTextBox, 'FTA', …);
}
}
// Linea MTP — arancio, visibile dal secondo passo
if (measurementState >= TPAState.MEDIAL_PLATEAU_START) {
drawLine(ctx, element, handles.mtpStart, handles.mtpEnd,
{ color: 'rgb(255, 160, 60)', lineWidth: 2 });
if (measurementState >= TPAState.COMPLETE) {
drawLinkedTextBox(ctx, element, handles.mtpTextBox, 'MTP', …);
}
}
// Reference Line + arco + label TPA — solo a misura completa
if (measurementState >= TPAState.COMPLETE) {
drawLine(ctx, element, handles.refStart, handles.refEnd,
{ color, lineDash: [6, 4] });
this.drawAngleArc(ctx, element, handles, cachedStats, color);
drawLinkedTextBox(ctx, element, handles.tpaTextBox,
[`TPA = ${cachedStats.tpaAngle}°`], …);
}
});
}
L’arco angolare viene disegnato in canvas coordinates tramite pixelToCanvas sull’intersectionPoint, e sceglie automaticamente il lato acuto confrontando quale direzione del vettore MTP (normale o opposta) forma l’arco minore con la Reference Line.
pointNearTool — hit testing su handle e linee
A differenza di tool semplici che verificano solo la prossimità a un segmento, il TPA deve testare sia gli handle individuali che i segmenti, e farlo rispettando lo stato corrente della misura:
pointNearTool(element, data, coords, interactionType): boolean {
if (!data?.handles) return false;
const distance = 30;
const { handles } = data;
// Test prossimità agli handle puntiformi (quadratica, senza sqrt)
for (const h of [handles.ftaStart, handles.ftaEnd, handles.mtpStart, handles.mtpEnd]) {
if (this.isPointNearHandle(element, h, coords, distance)) return true;
}
// Test distanza dal segmento FTA
if (this.isPointNearLine(element, handles.ftaStart, handles.ftaEnd, coords, distance))
return true;
// Test MTP — solo se il passo è stato raggiunto
if (data.measurementState >= TPAState.MEDIAL_PLATEAU_START) {
if (this.isPointNearLine(element, handles.mtpStart, handles.mtpEnd, coords, distance))
return true;
}
return false;
}
La distanza punto-segmento usa la proiezione parametrica clampata a [0, 1] — così cliccare fuori dal segmento ma vicino all’estensione della retta non attiva il tool:
distanceToLineSegment(point, lineStart, lineEnd): number {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
const t = Math.max(0, Math.min(1,
((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy)
/ (dx*dx + dy*dy)
));
return Math.sqrt(
(point.x - (lineStart.x + t*dx))**2 +
(point.y - (lineStart.y + t*dy))**2
);
}
7. Flussi Comuni
7.1 Flusso di Creazione — tool standard (addNewMeasurement)
mousedown (canvas vuoto, nessuna misura vicina)
│
▼
mouseDownActivateCallback(evt)
│
▼
addNewMeasurement(evt, ‘mouse’)
│
├─→ createNewMeasurement(eventData)
│ └─→ ritorna oggetto measurementData con handles
│
├─→ cornerstoneTools.addToolState(element, toolName, measurementData)
│
├─→ triggerEvent → MEASUREMENT_ADDED
│
├─→ cornerstone.updateImage(element) ← primo render
│
└─→ [loop] mouseDragCallback
│ • aggiorna handles.end con currentPoints.image
│ • data.invalidated = true
│ • cornerstone.updateImage(element)
│
▼
mouseUpCallback
│
├─→ data.active = false
├─→ triggerEvent → MEASUREMENT_COMPLETED
└─→ cornerstone.updateImage(element)
7.2 Flusso di Modifica — handle drag
mousedown (vicino a un handle)
│
▼
pointNearTool() → true
│
▼
handleSelectedCallback(evt, toolData, handle, ‘mouse’)
│
├─→ handle.active = true
├─→ toolData.active = true
├─→ activeCallback(element, toolData)
│
└─→ [loop] mouseDragCallback
│ • handle.x = currentPoints.image.x
│ • handle.y = currentPoints.image.y
│ • data.invalidated = true
│ • triggerEvent → MEASUREMENT_MODIFIED
│ • cornerstone.updateImage(element)
│
▼
mouseUpCallback
│
├─→ handle.active = false
├─→ toolData.active = false
└─→ cornerstone.updateImage(element)
7.3 Flusso TPA multi-step
[Stato: IDLE]
mousedown → addNewMeasurement → crea annotation → FUNCTIONAL_AXIS_START
│
▼
[Stato: FUNCTIONAL_AXIS_START]
mouseMoveCallback / mouseDragCallback → aggiorna ftaEnd → updateImage
│
▼ (drag release) ▼ (click senza drag)
mouseUpCallback preMouseDownCallback
│ │
└───────────────┬───────────────────┘
▼
FUNCTIONAL_AXIS_END
│
▼
[Stato: FUNCTIONAL_AXIS_END]
mousedown → addNewMeasurement → inizializza mtpStart/mtpEnd → MEDIAL_PLATEAU_START
│
▼
[Stato: MEDIAL_PLATEAU_START]
mouseMoveCallback / mouseDragCallback → aggiorna mtpEnd → updateImage
│
▼ (drag release) ▼ (click senza drag)
mouseUpCallback preMouseDownCallback
│ │
└───────────────┬───────────────────┘
▼
computeReferenceLineAndAngle()
→ refStart/refEnd + tpaAngle
│
▼
COMPLETE → IDLE
currentAnnotation = null
8. Concetti Chiave
8.1 Binding degli Handle
Gli handle sono oggetti plain JavaScript nel measurementData. Il sistema li gestisce tramite moveHandle e moveAllHandles:
// moveHandle — muove un singolo handle
cornerstoneTools.moveHandle(
evtDetail,
toolName,
annotation,
handle,
() => { /* onInteractiveChange — chiamato ad ogni frame */ },
() => { /* doneMovingCallback — chiamato al mouseup */ }
);
// moveAllHandles — muove tutta la misura (pan)
cornerstoneTools.moveAllHandles(
evt,
toolData,
toolData.handles,
null,
interactionType,
() => { /* done */ }
);
8.2 Invalidation — il flag invalidated
Il pattern invalidated è un’ottimizzazione fondamentale per evitare calcoli costosi ad ogni frame:
// Quando qualcosa cambia geometricamente:
data.invalidated = true;
cornerstone.updateImage(element);
// In renderToolData:
if (data.invalidated) {
this.calculateCachedStats(data, viewport, image);
data.invalidated = false;
}
Nel TPA, computeReferenceLineAndAngle viene chiamata esplicitamente solo al completamento di ogni passo — non ad ogni mousemove — per lo stesso motivo.
8.3 Coordinate — il sistema a tre livelli
┌─────────────────────────────────────────────────────────────┐
│ PAGE / CLIENT coords │
│ • Relative alla finestra browser │
│ • Usate per posizionare tooltip e popover │
├─────────────────────────────────────────────────────────────┤
│ CANVAS coords │
│ • Pixel fisici del <canvas> HTML │
│ • Usate per il draw (canvasContext.lineTo ecc.) │
├─────────────────────────────────────────────────────────────┤
│ IMAGE coords ← il tuo sistema di riferimento │
│ • Indipendenti da zoom, pan, flip, rotation │
│ • Salvate in measurementData.handles │
│ • Convertite al render con pixelToCanvas() │
└─────────────────────────────────────────────────────────────┘
Regola d’oro: salva sempre in coordinate immagine, converti solo quando disegni.
// ✅ Corretto — indipendente da zoom e pan
data.handles.ftaEnd.x = eventData.currentPoints.image.x;
data.handles.ftaEnd.y = eventData.currentPoints.image.y;
// ❌ Sbagliato — le coordinate canvas cambiano con zoom/pan
data.handles.ftaEnd.x = eventData.currentPoints.canvas.x;
// Nel TPA, drawAngleArc converte intersectionPoint con pixelToCanvas prima di context.arc().
8.4 State Machine vs flag booleani
Il TPA mostra chiaramente perché uno stato complesso merita un enum dedicato invece di flag booleani separati:
// ❌ Fragile — combinazioni impossibili, logica distribuita
let ftaDrawn = false;
let mtpStarted = false;
let isDragging = false;
let isComplete = false;
// ✅ Robusto — stato unico, transizioni esplicite
enum TPAState { IDLE, FUNCTIONAL_AXIS_START, FUNCTIONAL_AXIS_END, … }
Salvare measurementState nell’oggetto dati (non solo nella classe) è cruciale: permette a renderToolData di renderizzare correttamente anche le misure caricate da database o da sessioni precedenti, senza dover ricostruire lo stato da flag separati.
8.5 Tool State: dove vivono i dati
// Struttura interna
{
[element]: {
[toolName]: {
data: [
{ uuid, handles, active, visible, invalidated, measurementState, … },
]
}
}
}
// API pubblica
cornerstoneTools.addToolState(element, toolName, measurementData);
cornerstoneTools.getToolState(element, toolName); // → { data: […] }
cornerstoneTools.removeToolState(element, toolName, data);
cornerstoneTools.clearToolState(element, toolName);
Il toolState è per-element e per-toolName. Più viewport che mostrano la stessa immagine hanno toolState indipendenti — sincronizzazione manuale se necessario.
9. Checklist per Strumenti Custom
Quando implementi un nuovo strumento che estende BaseAnnotationTool, assicurati di gestire:
- createNewMeasurement — struttura iniziale degli handles
- pointNearTool — hit-testing per click e hover
- renderToolData — draw sul canvas, conversione coordinate, gestione invalidated
- calculateCachedStats — metriche costose, solo se invalidated
- handleSelectedCallback / toolSelectedCallback — risposta alla selezione
Se il tuo strumento ha un flusso multi-step come il TPA, aggiungi:
- Una state machine esplicita (enum) sia nella classe che nel measurementData
- Override di mouseMoveCallback per il preview in tempo reale
- Override di preMouseDownCallback per intercettare click prima di addNewMeasurement
Gestione separata di drag (mouseUpCallback) e click (preMouseDownCallback) per la stessa transizione di stato
import cornerstoneTools from 'cornerstone-tools';
const BaseAnnotationTool = cornerstoneTools.importInternal('base/BaseAnnotationTool');
export default class MyMultiStepTool extends BaseAnnotationTool {
private currentState: MyState = MyState.IDLE;
private currentAnnotation: any | null = null;
private isDragging = false;
constructor(props: any = {}) {
super(props, { name: 'MyMultiStepTool', supportedInteractionTypes: ['Mouse', 'Touch'] });
}
createNewMeasurement(eventData) { /* … */ }
addNewMeasurement(evt) { /* gestisce transizioni stato */ }
preMouseDownCallback(evt) { /* completa passo corrente prima del prossimo */ }
mouseMoveCallback(evt) { /* preview in tempo reale */ }
mouseDragCallback(evt) { /* aggiorna handle attivo + isDragging = true */ }
mouseUpCallback(evt) { /* completa passo via drag */ }
pointNearTool(element, data, coords, interactionType) { /* hit test */ }
renderToolData(evt) { /* rendering condizionale per stato */ }
}
10. Registrare e Attivare il Tool in Larvitar
Larvitar è un toolkit DICOM per CornerstoneJS che astrae l’inizializzazione degli strumenti, la gestione dei viewport e il lifecycle delle immagini. Espone una API di alto livello che wrappa cornerstoneTools con un sistema di tool default configurabili e una funzione registerExternalTool dedicata ai tool custom.
Registrazione
Il custom tool va registrato prima di aggiungere i tool al viewport, così Larvitar lo include automaticamente nel ciclo addDefaultTools. La funzione registerExternalTool accetta il nome del tool e la classe, e la aggiunge sia al registro interno dvTools che all’oggetto DEFAULT_TOOLS:
import { initializeCSTools, registerExternalTool, addDefaultTools, setToolActive }
from 'larvitar';
import MyCustomTool from './tools/MyCustomTool';
// 1. Inizializza l'ambiente cornerstoneTools
initializeCSTools();
// 2. Registra il tool custom prima di aggiungere i tool al viewport
registerExternalTool('MyCustomTool', MyCustomTool);
// 3. Aggiungi tutti i tool (default + il tuo) al viewport
addDefaultTools('viewer'); // 'viewer' è l'id dell'elemento HTML del viewport
// 4. Attiva il tool
setToolActive('MyCustomTool');
// Se vuoi registrarlo ma non includerlo in addDefaultTools — ad esempio perché lo attivi solo su certi viewport — puoi chiamare addTool manualmente dopo la registrazione:
import { initializeCSTools, registerExternalTool, addTool, setToolActive } from 'larvitar';
import MyCustomTool from './tools/MyCustomTool';
initializeCSTools();
registerExternalTool('MyCustomTool', MyCustomTool);
// Aggiunge il tool solo a un viewport specifico
addTool('MyCustomTool', {}, 'viewer');
// Attiva su tutti i viewport o su un sottoinsieme
setToolActive('MyCustomTool', { mouseButtonMask: 1 }, ['viewer']);
Stati del tool in Larvitar
Larvitar espone quattro funzioni per gestire lo stato del tool, corrispondenti agli stati di CornerstoneTools:
Funzione | Stato | Effetto |
|---|---|---|
setToolActive(name, options?, viewports?) | Active | Il tool risponde all’input e disegna |
setToolEnabled(name, viewports?) | Enabled | Render visibile, nessuna interazione |
setToolPassive(name, viewports?) | Passive | Hover e selezione, nessun disegno attivo |
setToolDisabled(name, viewports?) | Disabled | Invisibile, nessuna interazione |
Esempio completo con TPAAnnotationTool
import { initializeCSTools, registerExternalTool, addDefaultTools, setToolActive,
setToolDisabled } from 'larvitar';
import TPAAnnotationTool from './tools/TPAAnnotationTool';
// Setup iniziale (tipicamente all'avvio dell'app o del componente viewer)
initializeCSTools({ showSVGCursors: true });
registerExternalTool('TPAAnnotation', TPAAnnotationTool);
addDefaultTools('dicom-viewer');
// Quando l'utente clicca "Misura TPA"
setToolActive('TPAAnnotation', { mouseButtonMask: 1 }, ['dicom-viewer']);
// Quando l'utente cambia strumento
setToolDisabled('TPAAnnotation', ['dicom-viewer']);
setToolActive('Wwwc', { mouseButtonMask: 1 }, ['dicom-viewer']);
Il mouseButtonMask: 1 indica il tasto sinistro del mouse. Larvitar usa questo valore nei DEFAULT_MOUSE_KEYS per gestire l’attivazione condizionale in base a modificatori (shift, ctrl) — il tuo tool custom segue lo stesso schema senza configurazione aggiuntiva.
