React Takeoff Setup
A complete NPM React workspace: document header, measurement ribbon, calibration and calculator sidebar, scale library, and Canvas viewer.
Create a Vite React application and add the Viewer NPM package. The following files form one working takeoff screen; replace the sample document URL before use.
npm create vite@latest takeoff-viewer-react -- --template react-ts cd takeoff-viewer-react npm install @rasterex/viewer npm run dev
Keep the host UI state explicit. Canvas remains the source of truth for scales; this state is only the rendered snapshot and the active calibration session.
export type MeasurementScale = {
label: string;
value: string;
metric: '0' | '1' | 'METRIC' | 'IMPERIAL' | 0 | 1 | 2;
metricUnit: 'Millimeter' | 'Centimeter' | 'Decimeter' | 'Meter' | 'Kilometer' | 'Inch' | 'Feet' | 'Yard' | 'Mile' | 'Nautical Miles';
dimPrecision: number;
isSelected: boolean;
preciseValue?: number;
isGlobal?: boolean;
pageRanges?: [number, number][];
imperialNumerator?: number;
imperialDenominator?: number;
};
export type ToolName = 'select' | 'length' | 'area' | 'path';
export type SidebarTab = 'calibrate' | 'calculator';
export type CalibrationStep = 'ready' | 'picking' | 'picked' | 'candidate' | 'error';
export type TakeoffState = {
isReady: boolean;
isDocumentLoading: boolean;
isSidebarOpen: boolean;
activeScale: MeasurementScale | null;
scales: MeasurementScale[];
activeTool: ToolName;
activeTab: SidebarTab;
calibrationStep: CalibrationStep;
measuredLength: string | null;
candidateLabel: string | null;
message: string;
error: string | null;
};This is the complete integration. It opens a drawing, consumes scale snapshots, runs the NPM calibration request lifecycle, applies a calculated scale, activates tools, and adds or activates known scales. No button marks a scale active: the snapshot or apply result does.
import { useEffect, useRef, useState } from 'react';
import { createViewer, type RasterexViewer } from '@rasterex/viewer';
import type { MeasurementScale, TakeoffState, ToolName } from '../types/takeoff';
const fileIndex = 0;
const documentUrl = 'https://files.example.com/floor-plan.pdf';
const initialState: TakeoffState = {
isReady: false,
isDocumentLoading: true,
isSidebarOpen: true,
activeScale: null,
scales: [],
activeTool: 'select',
activeTab: 'calibrate',
calibrationStep: 'ready',
measuredLength: null,
candidateLabel: null,
message: 'Opening drawing.',
error: null,
};
function displayNameFromUrl(url: string) {
const name = url.split('/').pop();
return name || 'Floor plan.pdf';
}
export function useTakeoffViewer() {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<RasterexViewer | null>(null);
const requestIdRef = useRef<string | null>(null);
const [state, setState] = useState<TakeoffState>(initialState);
function updateState(patch: Partial<TakeoffState>) {
setState((current) => ({ ...current, ...patch }));
}
function useSnapshot(snapshot: { scales: MeasurementScale[]; selectedLabel?: string }) {
const selected = snapshot.scales.find((scale) => scale.isSelected) ?? null;
updateState({
activeScale: selected,
scales: snapshot.scales,
});
}
useEffect(() => {
if (!hostRef.current) return;
const viewer = createViewer({ container: hostRef.current });
viewerRef.current = viewer;
const offSnapshot = viewer.measurements.scale.on('snapshot', useSnapshot);
const offFinished = viewer.measurements.calibration.on('finished', (event) => {
if (!event.isFinished || event.requestId !== requestIdRef.current) return;
updateState({
calibrationStep: 'picked',
measuredLength: String(event.measuredLength ?? ''),
message: 'Reference line picked. Enter its real-world length.',
error: null,
});
});
const offCalculated = viewer.measurements.calibration.on('scaleCalculated', (event) => {
if (event.requestId !== requestIdRef.current) return;
updateState({
calibrationStep: 'candidate',
candidateLabel: event.scale.label,
message: 'Scale calculated. Review and apply it.',
error: null,
});
});
async function start() {
try {
await viewer.mount();
await viewer.ready();
updateState({ isReady: true });
await openDocument(documentUrl);
} catch (caughtError) {
updateState({
message: 'The drawing could not be opened.',
error: caughtError instanceof Error ? caughtError.message : 'Viewer setup failed.',
});
}
}
void start();
return () => {
const requestId = requestIdRef.current;
if (requestId) viewer.measurements.calibration.cancel({ requestId, fileIndex });
offSnapshot();
offFinished();
offCalculated();
viewer.destroy();
viewerRef.current = null;
};
}, []);
async function openDocument(url: string) {
const viewer = viewerRef.current;
if (!viewer || !url.trim()) return;
try {
updateState({
isDocumentLoading: true,
message: 'Opening ' + displayNameFromUrl(url) + '.',
error: null,
});
await viewer.documents.open({
url,
displayName: displayNameFromUrl(url),
});
const snapshot = await viewer.measurements.scale.get({ fileIndex });
useSnapshot(snapshot);
updateState({
isDocumentLoading: false,
message: 'Drawing ready. Set or calibrate a scale before measuring.',
});
} catch (caughtError) {
updateState({
isDocumentLoading: false,
message: 'The drawing could not be opened.',
error: caughtError instanceof Error ? caughtError.message : 'Document open failed.',
});
}
}
function startCalibration() {
const viewer = viewerRef.current;
if (!viewer) return;
const requestId = viewer.measurements.calibration.start({ fileIndex });
requestIdRef.current = requestId;
updateState({
calibrationStep: 'picking',
measuredLength: null,
candidateLabel: null,
message: 'Calibration active: pick one known reference line in Canvas.',
error: null,
});
}
function cancelCalibration() {
const viewer = viewerRef.current;
const requestId = requestIdRef.current;
if (viewer && requestId) viewer.measurements.calibration.cancel({ requestId, fileIndex });
requestIdRef.current = null;
updateState({
calibrationStep: 'ready',
measuredLength: null,
candidateLabel: null,
message: 'Calibration cancelled.',
error: null,
});
}
async function calculateMetric(knownLength: number, metricUnit: 'Millimeter' | 'Centimeter' | 'Decimeter' | 'Meter' | 'Kilometer') {
const viewer = viewerRef.current;
const requestId = requestIdRef.current;
if (!viewer || !requestId || knownLength <= 0) return;
try {
const candidate = await viewer.measurements.calibration.calculate({
requestId,
fileIndex,
measurementSystem: 1,
metricUnit,
calibrateCorrectionMetricValue: knownLength,
dimPrecision: 2,
});
updateState({ calibrationStep: 'candidate', candidateLabel: candidate.scale.label, error: null });
} catch (caughtError) {
updateState({
calibrationStep: 'error',
error: caughtError instanceof Error ? caughtError.message : 'Scale calculation failed.',
});
}
}
async function applyCalibration() {
const viewer = viewerRef.current;
const requestId = requestIdRef.current;
if (!viewer || !requestId) return;
try {
const snapshot = await viewer.measurements.calibration.apply({ requestId, fileIndex });
useSnapshot(snapshot);
requestIdRef.current = null;
updateState({
calibrationStep: 'ready',
measuredLength: null,
candidateLabel: null,
message: 'Scale applied. Measurement tools are ready.',
error: null,
});
} catch (caughtError) {
updateState({ error: caughtError instanceof Error ? caughtError.message : 'Scale could not be applied.' });
}
}
async function selectTool(tool: ToolName) {
const viewer = viewerRef.current;
if (!viewer) return;
if (tool === 'select') {
viewer.tools.clear();
updateState({ activeTool: 'select', message: 'Pointer navigation enabled.' });
return;
}
if (!state.activeScale) {
updateState({ error: 'Choose or calibrate a scale before measuring.' });
return;
}
const action = tool === 'length' ? 'MEASURE_LENGTH' : tool === 'area' ? 'MEASURE_AREA' : 'MEASURE_PATH';
const result = await viewer.tools.set({ action, enabled: true });
if (result.success) updateState({ activeTool: tool, message: tool + ' measurement enabled.', error: null });
else updateState({ error: result.error ?? 'The measurement tool was not enabled.' });
}
async function selectScale(scale: MeasurementScale) {
const viewer = viewerRef.current;
if (!viewer) return;
viewer.measurements.scale.add({
fileIndex,
scale: {
label: scale.label,
value: scale.value,
preciseValue: scale.preciseValue,
metric: scale.metric,
metricUnit: scale.metricUnit,
dimPrecision: scale.dimPrecision,
isSelected: true,
isGlobal: scale.isGlobal,
pageRanges: scale.pageRanges,
imperialNumerator: scale.imperialNumerator,
imperialDenominator: scale.imperialDenominator,
},
});
const snapshot = await viewer.measurements.scale.get({ fileIndex });
useSnapshot(snapshot);
updateState({ message: 'Scale selection confirmed by Canvas.', error: null });
}
async function addManualMetricScale(label: string, value: string, metricUnit: 'Millimeter' | 'Centimeter' | 'Decimeter' | 'Meter' | 'Kilometer', precision: number) {
const viewer = viewerRef.current;
if (!viewer || !label || !value || precision < 0) return;
viewer.measurements.scale.add({
fileIndex,
scale: { label, value, metric: 'METRIC', metricUnit, dimPrecision: precision, isSelected: true },
});
const snapshot = await viewer.measurements.scale.get({ fileIndex });
useSnapshot(snapshot);
updateState({ message: 'Manual scale confirmed by Canvas.', error: null });
}
return {
hostRef,
state,
openDocument,
toggleSidebar: () => updateState({ isSidebarOpen: !state.isSidebarOpen }),
setActiveTab: (activeTab: 'calibrate' | 'calculator') => updateState({ activeTab }),
startCalibration,
cancelCalibration,
calculateMetric,
applyCalibration,
selectTool,
selectScale,
addManualMetricScale,
};
}The ribbon remains visible above the drawing. Its tool buttons are disabled until Canvas has confirmed an active scale, and the selected button reflects the host state.
import type { ToolName } from '../types/takeoff';
type Props = {
activeTool: ToolName;
disabled: boolean;
onSelectTool: (tool: ToolName) => void;
};
const tools: { label: string; value: ToolName }[] = [
{ label: 'Select', value: 'select' },
{ label: 'Length', value: 'length' },
{ label: 'Area', value: 'area' },
{ label: 'Path', value: 'path' },
];
export function TakeoffRibbon({ activeTool, disabled, onSelectTool }: Props) {
return (
<nav className="ribbon" aria-label="Measurement tools">
{tools.map((tool) => (
<button
key={tool.value}
type="button"
className={activeTool === tool.value ? 'tool is-active' : 'tool'}
aria-pressed={activeTool === tool.value}
disabled={tool.value !== 'select' && disabled}
onClick={() => onSelectTool(tool.value)}
>
{tool.label}
</button>
))}
</nav>
);
}This implements the reference workflow as a collapsible work sidebar: active-scale summary, Calibrate and Calculator tabs, a floating pick state, and a scale library. The library selects known complete scale objects; it does not expose a Canvas delete action because NPM has none.
import { useState } from 'react';
import type { MeasurementScale, TakeoffState } from '../types/takeoff';
type Props = {
state: TakeoffState;
onTabChange: (tab: 'calibrate' | 'calculator') => void;
onStartCalibration: () => void;
onCancelCalibration: () => void;
onCalculateMetric: (length: number, unit: 'Millimeter' | 'Centimeter' | 'Decimeter' | 'Meter' | 'Kilometer') => void;
onApplyCalibration: () => void;
onAddManualScale: (label: string, value: string, unit: 'Millimeter' | 'Centimeter' | 'Decimeter' | 'Meter' | 'Kilometer', precision: number) => void;
onSelectScale: (scale: MeasurementScale) => void;
};
export function TakeoffSidebar(props: Props) {
const [knownLength, setKnownLength] = useState('1');
const [unit, setUnit] = useState<'Millimeter' | 'Centimeter' | 'Decimeter' | 'Meter' | 'Kilometer'>('Meter');
const [label, setLabel] = useState('1 m : 10 m');
const [value, setValue] = useState('1:10');
const [precision, setPrecision] = useState('2');
const isPicking = props.state.calibrationStep === 'picking';
const canCalculate = props.state.calibrationStep === 'picked' || props.state.calibrationStep === 'error';
return (
<aside className="sidebar" aria-label="Takeoff setup">
<section className="scale-card">
<span>Active scale</span>
<strong>{props.state.activeScale?.label ?? 'Uncalibrated'}</strong>
<small>{props.state.activeScale?.value ?? 'Set a scale before measuring'}</small>
</section>
<div className="tabs" role="tablist" aria-label="Scale setup">
<button
type="button"
role="tab"
aria-selected={props.state.activeTab === 'calibrate'}
onClick={() => props.onTabChange('calibrate')}
>
Calibrate
</button>
<button
type="button"
role="tab"
aria-selected={props.state.activeTab === 'calculator'}
onClick={() => props.onTabChange('calculator')}
>
Calculator
</button>
</div>
{props.state.activeTab === 'calibrate' ? (
<section className="panel">
<h2>Calibrate drawing</h2>
<ol>
<li>Start and pick a known line.</li>
<li>Enter its real-world length.</li>
<li>Calculate, review, then apply.</li>
</ol>
<button type="button" disabled={isPicking} onClick={props.onStartCalibration}>
Start calibration
</button>
{isPicking ? (
<button type="button" className="secondary" onClick={props.onCancelCalibration}>
Cancel calibration
</button>
) : null}
<label>
Picked Canvas length
<input readOnly value={props.state.measuredLength ?? 'Waiting for a line'} />
</label>
<label>
Known real-world length
<input
type="number"
min="0"
step="any"
value={knownLength}
onChange={(event) => setKnownLength(event.target.value)}
/>
</label>
<label>
Unit
<select value={unit} onChange={(event) => setUnit(event.target.value as typeof unit)}>
<option value="Millimeter">Millimeter</option>
<option value="Centimeter">Centimeter</option>
<option value="Meter">Meter</option>
<option value="Kilometer">Kilometer</option>
</select>
</label>
<button
type="button"
disabled={!canCalculate || Number(knownLength) <= 0}
onClick={() => props.onCalculateMetric(Number(knownLength), unit)}
>
Calculate scale
</button>
<p className="candidate">
{props.state.candidateLabel
? 'Candidate: ' + props.state.candidateLabel
: 'No calculated scale yet.'}
</p>
<button
type="button"
disabled={props.state.calibrationStep !== 'candidate'}
onClick={props.onApplyCalibration}
>
Apply scale
</button>
</section>
) : (
<section className="panel">
<h2>Manual scale calculator</h2>
<p>Create a full metric scale, then wait for Canvas to confirm it as active.</p>
<label>
Label
<input value={label} onChange={(event) => setLabel(event.target.value)} />
</label>
<label>
Value
<input value={value} onChange={(event) => setValue(event.target.value)} />
</label>
<label>
Unit
<select value={unit} onChange={(event) => setUnit(event.target.value as typeof unit)}>
<option value="Millimeter">Millimeter</option>
<option value="Centimeter">Centimeter</option>
<option value="Meter">Meter</option>
<option value="Kilometer">Kilometer</option>
</select>
</label>
<label>
Precision
<input
type="number"
min="0"
step="1"
value={precision}
onChange={(event) => setPrecision(event.target.value)}
/>
</label>
<button
type="button"
disabled={!label || !value || Number(precision) < 0}
onClick={() => props.onAddManualScale(label, value, unit, Number(precision))}
>
Add and activate scale
</button>
</section>
)}
<section className="scale-library">
<h2>Scale library</h2>
{props.state.scales.map((scale) => (
<button
key={scale.label + scale.value}
type="button"
className={scale.isSelected ? 'scale-row is-selected' : 'scale-row'}
onClick={() => props.onSelectScale(scale)}
>
<strong>{scale.label}</strong>
<small>{scale.value}</small>
</button>
))}
</section>
</aside>
);
}Compose the reference-style document header, engineering ribbon, collapsible sidebar, and Canvas workspace. The banner is shown only while Canvas is waiting for the operator’s reference-line pick.
import { useState } from 'react';
import './App.css';
import { TakeoffRibbon } from './components/TakeoffRibbon';
import { TakeoffSidebar } from './components/TakeoffSidebar';
import { useTakeoffViewer } from './hooks/useTakeoffViewer';
export default function App() {
const takeoff = useTakeoffViewer();
const [url, setUrl] = useState('https://files.example.com/floor-plan.pdf');
const toolsDisabled = !takeoff.state.activeScale || takeoff.state.calibrationStep === 'picking';
return (
<main className="app-shell">
<header className="app-header">
<div className="brand-section">
<button
type="button"
className="icon-button"
aria-label="Toggle takeoff sidebar"
aria-pressed={takeoff.state.isSidebarOpen}
onClick={takeoff.toggleSidebar}
>
☰
</button>
<div>
<strong>Rasterex Web Viewer</strong>
<small>Measurements and calibration</small>
</div>
</div>
<form
className="document-form"
onSubmit={(event) => {
event.preventDefault();
void takeoff.openDocument(url);
}}
>
<input
type="url"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="Enter remote document URL"
disabled={!takeoff.state.isReady}
/>
<button type="submit" disabled={!takeoff.state.isReady || !url.trim()}>
Open drawing
</button>
</form>
<span className={takeoff.state.activeScale ? 'connection ready' : 'connection'}>
{takeoff.state.isReady ? 'Canvas ready' : 'Connecting'}
</span>
</header>
<TakeoffRibbon
activeTool={takeoff.state.activeTool}
disabled={toolsDisabled}
onSelectTool={(tool) => void takeoff.selectTool(tool)}
/>
<div className={takeoff.state.isSidebarOpen ? 'workspace sidebar-open' : 'workspace'}>
<TakeoffSidebar
state={takeoff.state}
onTabChange={takeoff.setActiveTab}
onStartCalibration={takeoff.startCalibration}
onCancelCalibration={takeoff.cancelCalibration}
onCalculateMetric={(length, unit) => void takeoff.calculateMetric(length, unit)}
onApplyCalibration={() => void takeoff.applyCalibration()}
onAddManualScale={(label, value, unit, precision) =>
void takeoff.addManualMetricScale(label, value, unit, precision)
}
onSelectScale={(scale) => void takeoff.selectScale(scale)}
/>
<section className="viewer-workspace">
{takeoff.state.calibrationStep === 'picking' ? (
<p className="calibration-banner" role="status">
Calibration active: pick a known reference line.
</p>
) : null}
{takeoff.state.error ? (
<p className="error" role="alert">{takeoff.state.error}</p>
) : (
<p className="status" role="status">{takeoff.state.message}</p>
)}
{takeoff.state.isDocumentLoading ? (
<div className="viewer-loading" role="status">Opening document…</div>
) : null}
<div ref={takeoff.hostRef} className="viewer-host" />
</section>
</div>
</main>
);
}This creates the reference-style product surface: dark document header, engineering ribbon, collapsible working sidebar, full-height viewer, loading state, and unmistakable calibration state.
:root { color: #f8fafc; background: #080c14; font-family: Inter, system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { display: grid; grid-template-rows: 52px 52px minmax(0, 1fr); min-height: 100vh; }
.app-header { display: grid; grid-template-columns: minmax(190px, 1fr) minmax(260px, 560px) minmax(110px, 1fr); gap: 16px; align-items: center; padding: 0 20px; background: #0f172a; border-bottom: 1px solid #334155; }
.brand-section { display: flex; gap: 10px; align-items: center; }
.brand-section small { display: block; margin-top: 2px; color: #94a3b8; font-size: 11px; }
.icon-button { width: 32px; height: 32px; color: #f8fafc; background: #1e293b; border: 1px solid #334155; border-radius: 6px; }
.document-form { display: flex; gap: 8px; }
.document-form input { min-width: 0; flex: 1; padding: 7px 10px; color: #f8fafc; background: #080c14; border: 1px solid #334155; border-radius: 6px; }
.document-form button { padding: 7px 10px; color: #fff; background: #22b14c; border: 0; border-radius: 6px; }
.connection { justify-self: end; color: #f59e0b; font-size: 12px; }
.connection.ready { color: #58d67c; }
.ribbon { display: flex; gap: 8px; align-items: center; padding: 8px 20px; background: #0f172a; border-bottom: 1px solid #334155; }
.tool { min-width: 76px; padding: 8px 10px; color: #cbd5e1; background: #1e293b; border: 1px solid #334155; border-radius: 6px; }
.tool:hover, .tool.is-active { color: #58d67c; background: rgb(34 177 76 / 15%); border-color: #22b14c; }
.tool:disabled { color: #64748b; background: #172033; cursor: not-allowed; }
.workspace { display: grid; grid-template-columns: 0 minmax(0, 1fr); min-height: 0; transition: grid-template-columns .25s ease; }
.workspace.sidebar-open { grid-template-columns: 330px minmax(0, 1fr); }
.sidebar { overflow: hidden; padding: 0; color: #f8fafc; background: #0f172a; border-right: 0; opacity: 0; transition: opacity .2s ease, padding .25s ease; }
.sidebar-open .sidebar { overflow-y: auto; padding: 16px; border-right: 1px solid #334155; opacity: 1; }
.scale-card, .panel, .scale-library { padding: 14px; margin-bottom: 14px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; }
.scale-card span, .scale-card small, .scale-row small { display: block; color: #94a3b8; font-size: 12px; }
.scale-card strong { display: block; margin: 5px 0; font-size: 16px; }
.tabs { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 14px; background: #080c14; border-radius: 7px; padding: 3px; }
.tabs button { padding: 8px; color: #94a3b8; background: transparent; border: 0; border-radius: 5px; }
.tabs button[aria-selected='true'] { color: #f8fafc; background: #1e293b; box-shadow: 0 1px 3px rgb(0 0 0 / 30%); }
.panel h2, .scale-library h2 { margin: 0 0 10px; font-size: 15px; }
.panel ol { padding-left: 20px; color: #94a3b8; font-size: 13px; line-height: 1.5; }
.panel label { display: grid; gap: 5px; margin-top: 10px; color: #cbd5e1; font-size: 12px; }
.panel input, .panel select { width: 100%; padding: 8px; color: #f8fafc; background: #080c14; border: 1px solid #334155; border-radius: 5px; }
.panel button { width: 100%; padding: 9px; margin-top: 12px; color: #fff; background: #22b14c; border: 0; border-radius: 5px; }
.panel button:disabled { opacity: .45; cursor: not-allowed; }
.panel .secondary { color: #cbd5e1; background: #334155; }
.candidate { min-height: 20px; color: #58d67c; font-size: 13px; }
.scale-library { margin-bottom: 0; }
.scale-row { display: block; width: 100%; padding: 10px; margin-top: 7px; color: #f8fafc; text-align: left; background: #0f172a; border: 1px solid #334155; border-radius: 5px; }
.scale-row.is-selected { border-color: #22b14c; background: rgb(34 177 76 / 10%); }
.viewer-workspace { position: relative; min-width: 0; min-height: 0; background: #080c14; }
.viewer-host { width: 100%; height: 100%; min-height: 620px; }
.status, .error, .calibration-banner { position: absolute; z-index: 2; left: 16px; top: 16px; max-width: 420px; padding: 9px 12px; margin: 0; border-radius: 6px; box-shadow: 0 4px 14px rgb(15 23 42 / 14%); font-size: 13px; }
.status { color: #334155; background: #fff; }
.error { color: #9f1239; background: #fff1f2; }
.calibration-banner { left: 50%; color: #fff; background: #0759c7; transform: translateX(-50%); }
.viewer-loading { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; color: #f8fafc; background: rgb(8 12 20 / 70%); }
@media (max-width: 800px) { .app-header { grid-template-columns: 1fr auto; } .document-form { grid-column: 1 / -1; grid-row: 2; padding-bottom: 10px; } .app-shell { grid-template-rows: 92px 52px minmax(0, 1fr); } .workspace.sidebar-open { grid-template-columns: 1fr; } .sidebar-open .sidebar { border-right: 0; border-bottom: 1px solid #334155; } .viewer-host { min-height: 520px; } }