Message APINPM Package/docs/workflows/takeoff/react-setup

React Takeoff Setup

A complete NPM React workspace: document header, measurement ribbon, calibration and calculator sidebar, scale library, and Canvas viewer.

1. Create And Install

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.

bash
npm create vite@latest takeoff-viewer-react -- --template react-ts
cd takeoff-viewer-react
npm install @rasterex/viewer
npm run dev
1. Create And Install

2. src/types/takeoff.ts

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.

typescript
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;
};
2. src/types/takeoff.ts

3. src/hooks/useTakeoffViewer.ts

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.

tsx
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,
  };
}
3. src/hooks/useTakeoffViewer.ts

4. src/components/TakeoffRibbon.tsx

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.

tsx
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>
  );
}
4. src/components/TakeoffRibbon.tsx

6. src/App.tsx

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.

tsx
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>
  );
}
6. src/App.tsx

7. src/App.css

This creates the reference-style product surface: dark document header, engineering ribbon, collapsible working sidebar, full-height viewer, loading state, and unmistakable calibration state.

css
: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; } }
7. src/App.css