Rasterex APINPM Package/docs/workflows/facility-management/react-setup

React Facility Management Setup

Build a React facility management workspace with the Rasterex NPM package. Users trace floor-plan spaces and manage room metadata linked to Canvas annotations.

1. Create And Install

Create the React app and install the Rasterex Viewer package.

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

2. File Structure

Split the Viewer lifecycle, room metadata state, side panel, dialog, and viewer host into separate files.

text
src/
  App.tsx
  App.css
  config/
    facilityConfig.ts
  types/
    facility.ts
  hooks/
    useFacilityManagementViewer.ts
  components/
    FacilitySpaceDialog.tsx
    FacilitySpacePanel.tsx
    FacilityStatusBar.tsx
    ViewerHost.tsx
2. File Structure

3. Environment Values

Set a floor-plan drawing URL that Canvas can load.

bash
VITE_FACILITY_PLAN_URL=https://files.example.com/floor-plan.pdf
VITE_FACILITY_PLAN_NAME=floor-plan.pdf
3. Environment Values

4. types/facility.ts

Create the room metadata, draft, Viewer state, and annotation event types.

typescript
export type FacilityViewerStatus = 'mounting' | 'ready' | 'drawing' | 'editing' | 'error';

export interface FacilitySpace {
  id: string;
  annotationId: string;
  roomTag: string;
  department: string;
  areaSqm: string;
  floor: string;
  capacity: string;
  cleaning: string;
  lastInspection: string;
  status: string;
  assets: string;
  openIssue: string;
  notes: string;
}

export type SpaceDraft = Omit<FacilitySpace, 'id' | 'annotationId'>;

export interface FacilityAnnotationEvent {
  guid?: string;
  uniqueId?: string;
  id?: string;
  markupId?: string;
  markupNumber?: string | number;
}
4. types/facility.ts

5. config/facilityConfig.ts

Centralize floor-plan settings, default room metadata, and polygon hatch styling.

typescript
import type { SpaceDraft } from '../types/facility';

export const facilityPlan = {
  url: import.meta.env.VITE_FACILITY_PLAN_URL || 'https://files.example.com/floor-plan.pdf',
  displayName: import.meta.env.VITE_FACILITY_PLAN_NAME || 'floor-plan.pdf',
};

export const initialSpaceDraft: SpaceDraft = {
  roomTag: 'Meeting room A-201',
  department: 'Facilities',
  areaSqm: '42',
  floor: '2nd floor',
  capacity: '12 people',
  cleaning: 'Daily',
  lastInspection: '12 May 2026',
  status: 'Good',
  assets: '1x Projector
2x Air sensors
1x HVAC unit',
  openIssue: 'Lighting issue near entrance',
  notes: '',
};

export const facilityHatchStyle = {
  color: '#16a34a',
  strokeColor: '#15803d',
  fillColor: '#22c55e',
  opacity: 0.35,
  strokeOpacity: 0.9,
  fillOpacity: 0.18,
  strokeWidth: 2,
};
5. config/facilityConfig.ts

6. hooks/useFacilityManagementViewer.ts

Mount the NPM Viewer once, activate polygon space mapping, capture created annotation IDs, and synchronize room selection.

tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { createViewer, type RasterexViewer } from '@rasterex/viewer';
import { facilityHatchStyle, facilityPlan, initialSpaceDraft } from '../config/facilityConfig';
import type { FacilityAnnotationEvent, FacilitySpace, FacilityViewerStatus, SpaceDraft } from '../types/facility';

export function useFacilityManagementViewer() {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const viewerRef = useRef<RasterexViewer | null>(null);
  const isHatchPendingRef = useRef(false);
  const pendingAnnotationIdRef = useRef<string | null>(null);
  const [viewerStatus, setViewerStatus] = useState<FacilityViewerStatus>('mounting');
  const [message, setMessage] = useState('Loading floor plan');
  const [spaces, setSpaces] = useState<FacilitySpace[]>([]);
  const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
  const [draft, setDraft] = useState<SpaceDraft>(initialSpaceDraft);
  const [pendingAnnotationId, setPendingAnnotationId] = useState<string | null>(null);
  const [editingSpaceId, setEditingSpaceId] = useState<string | null>(null);
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const [isHatchPending, setIsHatchPending] = useState(false);

  useEffect(() => {
    if (!hostRef.current) return undefined;

    let disposed = false;
    const viewer = createViewer({ container: hostRef.current });
    const cleanups: Array<() => void> = [];
    viewerRef.current = viewer;

    cleanups.push(
      viewer.annotations.on('created', (event: FacilityAnnotationEvent) => {
        if (disposed || !isHatchPendingRef.current) return;
        const annotationId = getAnnotationId(event);
        if (!annotationId) {
          isHatchPendingRef.current = false;
          setIsHatchPending(false);
          setViewerStatus('error');
          setMessage('Space boundary was created, but Canvas did not return an annotation ID.');
          return;
        }

        pendingAnnotationIdRef.current = annotationId;
        isHatchPendingRef.current = false;
        setPendingAnnotationId(annotationId);
        setIsHatchPending(false);
        setIsDialogOpen(true);
        setViewerStatus('editing');
        setMessage('Space boundary captured. Add room metadata to save it.');
      }),
      viewer.annotations.on('selected', (event: FacilityAnnotationEvent) => {
        const annotationId = getAnnotationId(event);
        if (!annotationId) return;

        setSpaces((current) => {
          const selected = current.find((space) => space.annotationId === annotationId);
          if (selected) openSpaceEditor(selected, 'Canvas');
          return current;
        });
      })
    );

    async function start() {
      await viewer.mount();
      await viewer.ready();
      await viewer.documents.open({
        url: facilityPlan.url,
        displayName: facilityPlan.displayName,
      });

      if (!disposed) {
        setViewerStatus('ready');
        setMessage('Floor plan ready. Add a space hatch.');
      }
    }

    void start().catch((error) => {
      setViewerStatus('error');
      setMessage(error instanceof Error ? error.message : 'Viewer failed to load.');
    });

    return () => {
      disposed = true;
      cleanups.forEach((cleanup) => cleanup());
      viewer.destroy();
      viewerRef.current = null;
    };
  }, []);

  const startSpaceHatch = useCallback(async () => {
    if (!viewerRef.current || viewerStatus === 'mounting') return;

    pendingAnnotationIdRef.current = null;
    isHatchPendingRef.current = true;
    setPendingAnnotationId(null);
    setEditingSpaceId(null);
    setIsDialogOpen(false);
    setIsHatchPending(true);
    setDraft({
      ...initialSpaceDraft,
      roomTag: spaces.length === 0 ? initialSpaceDraft.roomTag : 'Room ' + (204 + spaces.length),
    });

    const result = await viewerRef.current.tools.set({
      action: 'SHAPE_POLYGON',
      enabled: true,
      style: facilityHatchStyle,
    });

    if (result?.success === false) {
      isHatchPendingRef.current = false;
      setIsHatchPending(false);
      setViewerStatus('error');
      setMessage(result.error ?? 'Space hatch tool failed.');
      return;
    }

    setViewerStatus('drawing');
    setMessage('Polygon hatch is active. Draw the room boundary on Canvas.');
  }, [spaces.length, viewerStatus]);

  const saveSpace = useCallback(() => {
    const roomTag = draft.roomTag.trim();
    if (!roomTag) {
      setMessage('Room tag is required.');
      return;
    }

    if (editingSpaceId) {
      setSpaces((current) => current.map((space) => space.id === editingSpaceId ? {
        ...space,
        ...normalizeDraft(draft),
        roomTag,
      } : space));
      setSelectedSpaceId(editingSpaceId);
      setIsDialogOpen(false);
      setEditingSpaceId(null);
      setViewerStatus('ready');
      setMessage(roomTag + ' metadata updated.');
      return;
    }

    const annotationId = pendingAnnotationIdRef.current ?? pendingAnnotationId;
    if (!annotationId) {
      setMessage('Draw a room boundary before saving metadata.');
      return;
    }

    const nextSpace: FacilitySpace = {
      id: annotationId,
      annotationId,
      ...normalizeDraft(draft),
      roomTag,
    };

    setSpaces((current) => [nextSpace, ...current]);
    setSelectedSpaceId(nextSpace.id);
    pendingAnnotationIdRef.current = null;
    isHatchPendingRef.current = false;
    setIsDialogOpen(false);
    setPendingAnnotationId(null);
    setIsHatchPending(false);
    viewerRef.current?.tools.clear();
    setViewerStatus('ready');
    setMessage(nextSpace.roomTag + ' saved with ' + nextSpace.areaSqm + ' sqm.');
  }, [draft, editingSpaceId, pendingAnnotationId]);

  const cancelDialog = useCallback(() => {
    viewerRef.current?.tools.clear();
    pendingAnnotationIdRef.current = null;
    isHatchPendingRef.current = false;
    setIsDialogOpen(false);
    setPendingAnnotationId(null);
    setEditingSpaceId(null);
    setIsHatchPending(false);
    setViewerStatus('ready');
    setMessage('Space edit canceled.');
  }, []);

  const selectSpace = useCallback((space: FacilitySpace) => {
    openSpaceEditor(space, 'list');
    viewerRef.current?.annotations.select({ guid: space.annotationId });
  }, []);

  function openSpaceEditor(space: FacilitySpace, source: string) {
    setSelectedSpaceId(space.id);
    setEditingSpaceId(space.id);
    pendingAnnotationIdRef.current = null;
    isHatchPendingRef.current = false;
    setPendingAnnotationId(null);
    setIsHatchPending(false);
    setDraft(spaceToDraft(space));
    setIsDialogOpen(true);
    setViewerStatus('editing');
    setMessage(space.roomTag + ' selected from ' + source + '.');
  }

  return {
    hostRef,
    viewerStatus,
    message,
    spaces,
    selectedSpaceId,
    draft,
    isDialogOpen,
    isHatchPending,
    isReady: viewerStatus === 'ready',
    setDraft,
    startSpaceHatch,
    saveSpace,
    cancelDialog,
    selectSpace,
  };
}

function normalizeDraft(draft: SpaceDraft): SpaceDraft {
  return {
    roomTag: draft.roomTag.trim(),
    department: draft.department.trim() || 'Unassigned',
    areaSqm: draft.areaSqm.trim() || '0',
    floor: draft.floor.trim() || 'Unassigned',
    capacity: draft.capacity.trim() || 'Not set',
    cleaning: draft.cleaning.trim() || 'Not set',
    lastInspection: draft.lastInspection.trim() || 'Not recorded',
    status: draft.status.trim() || 'Not set',
    assets: draft.assets.trim(),
    openIssue: draft.openIssue.trim(),
    notes: draft.notes.trim(),
  };
}

function spaceToDraft(space: FacilitySpace): SpaceDraft {
  return {
    roomTag: space.roomTag,
    department: space.department,
    areaSqm: space.areaSqm,
    floor: space.floor,
    capacity: space.capacity,
    cleaning: space.cleaning,
    lastInspection: space.lastInspection,
    status: space.status,
    assets: space.assets,
    openIssue: space.openIssue,
    notes: space.notes,
  };
}

function getAnnotationId(event: FacilityAnnotationEvent): string | null {
  const resolvedId = event.guid ?? event.uniqueId ?? event.id ?? event.markupId ?? event.markupNumber;
  if (typeof resolvedId === 'string' && resolvedId.trim()) return resolvedId;
  if (typeof resolvedId === 'number') return String(resolvedId);
  return null;
}
6. hooks/useFacilityManagementViewer.ts

7. components/FacilityStatusBar.tsx

Show the workflow message and room totals above the space list.

tsx
type Props = {
  message: string;
  totalSpaces: number;
  issueCount: number;
};

export default function FacilityStatusBar({ message, totalSpaces, issueCount }: Props) {
  return <section className="status-bar" role="status" aria-live="polite">
    <div>
      <span>Facility Management</span>
      <p>{message}</p>
    </div>
    <dl>
      <div><dt>Spaces</dt><dd>{totalSpaces}</dd></div>
      <div><dt>Open issues</dt><dd>{issueCount}</dd></div>
    </dl>
  </section>;
}
7. components/FacilityStatusBar.tsx

8. components/FacilitySpacePanel.tsx

Render the saved room list and start polygon hatch mapping from the side panel.

tsx
import type { FacilitySpace } from '../types/facility';

type Props = {
  spaces: FacilitySpace[];
  selectedSpaceId: string | null;
  isReady: boolean;
  isHatchPending: boolean;
  onStartHatch: () => Promise<void>;
  onSelectSpace: (space: FacilitySpace) => void;
};

export default function FacilitySpacePanel({ spaces, selectedSpaceId, isReady, isHatchPending, onStartHatch, onSelectSpace }: Props) {
  return <aside className="space-panel">
    <button type="button" className="primary-action" disabled={!isReady || isHatchPending} onClick={() => void onStartHatch()}>
      {isHatchPending ? 'Draw room boundary' : 'Add space'}
    </button>

    <div className="space-list">
      {spaces.length === 0 ? <p className="empty-state">Saved room metadata appears here.</p> : spaces.map((space) => (
        <button className={selectedSpaceId === space.id ? 'space-card selected' : 'space-card'} type="button" key={space.id} onClick={() => onSelectSpace(space)}>
          <strong>{space.roomTag}</strong>
          <span>{space.floor} / {space.capacity}</span>
          <small>{space.areaSqm} sqm / {space.status}</small>
        </button>
      ))}
    </div>
  </aside>;
}
8. components/FacilitySpacePanel.tsx

9. components/FacilitySpaceDialog.tsx

Edit room metadata after the polygon annotation is created or when an existing space is selected.

tsx
import type { SpaceDraft } from '../types/facility';

type Props = {
  open: boolean;
  draft: SpaceDraft;
  onDraftChange: (draft: SpaceDraft) => void;
  onCancel: () => void;
  onSave: () => void;
};

export default function FacilitySpaceDialog({ open, draft, onDraftChange, onCancel, onSave }: Props) {
  if (!open) return null;

  return <div className="dialog-backdrop">
    <section className="space-dialog" role="dialog" aria-modal="true" aria-labelledby="space-dialog-title">
      <header>
        <span>Space metadata</span>
        <h2 id="space-dialog-title">Room details</h2>
        <p>Save metadata for the traced floor-plan boundary.</p>
      </header>

      <div className="field-row">
        <Field label="Room tag" value={draft.roomTag} onChange={(value) => onDraftChange({ ...draft, roomTag: value })} />
        <Field label="Department" value={draft.department} onChange={(value) => onDraftChange({ ...draft, department: value })} />
      </div>
      <div className="field-row">
        <Field label="Area (sqm)" value={draft.areaSqm} onChange={(value) => onDraftChange({ ...draft, areaSqm: value })} />
        <Field label="Floor" value={draft.floor} onChange={(value) => onDraftChange({ ...draft, floor: value })} />
        <Field label="Capacity" value={draft.capacity} onChange={(value) => onDraftChange({ ...draft, capacity: value })} />
      </div>
      <div className="field-row">
        <Field label="Cleaning" value={draft.cleaning} onChange={(value) => onDraftChange({ ...draft, cleaning: value })} />
        <Field label="Last inspection" value={draft.lastInspection} onChange={(value) => onDraftChange({ ...draft, lastInspection: value })} />
        <Field label="Status" value={draft.status} onChange={(value) => onDraftChange({ ...draft, status: value })} />
      </div>
      <TextareaField label="Assets" value={draft.assets} onChange={(value) => onDraftChange({ ...draft, assets: value })} />
      <Field label="Open issue" value={draft.openIssue} onChange={(value) => onDraftChange({ ...draft, openIssue: value })} />
      <TextareaField label="Notes" value={draft.notes} onChange={(value) => onDraftChange({ ...draft, notes: value })} />

      <footer>
        <button type="button" className="secondary-action" onClick={onCancel}>Cancel</button>
        <button type="button" className="primary-action" onClick={onSave}>Save space</button>
      </footer>
    </section>
  </div>;
}

type FieldProps = { label: string; value: string; onChange: (value: string) => void };

function Field({ label, value, onChange }: FieldProps) {
  return <label>{label}<input value={value} onChange={(event) => onChange(event.target.value)} /></label>;
}

function TextareaField({ label, value, onChange }: FieldProps) {
  return <label>{label}<textarea rows={4} value={value} onChange={(event) => onChange(event.target.value)} /></label>;
}
9. components/FacilitySpaceDialog.tsx

10. components/ViewerHost.tsx

Render the NPM Viewer mount point.

tsx
import type { RefObject } from 'react';

type Props = {
  hostRef: RefObject<HTMLDivElement | null>;
};

export default function ViewerHost({ hostRef }: Props) {
  return <section className="viewer-shell" aria-label="Rasterex viewer">
    <div ref={hostRef} className="viewer-host" />
  </section>;
}
10. components/ViewerHost.tsx

11. App.tsx

Compose the Viewer, space list, status summary, and metadata dialog.

tsx
import './App.css';
import FacilitySpaceDialog from './components/FacilitySpaceDialog';
import FacilitySpacePanel from './components/FacilitySpacePanel';
import FacilityStatusBar from './components/FacilityStatusBar';
import ViewerHost from './components/ViewerHost';
import { useFacilityManagementViewer } from './hooks/useFacilityManagementViewer';

export default function App() {
  const facility = useFacilityManagementViewer();
  const issueCount = facility.spaces.filter((space) => space.openIssue.trim()).length;

  return <main className="facility-workspace">
    <section className="sidebar">
      <FacilityStatusBar message={facility.message} totalSpaces={facility.spaces.length} issueCount={issueCount} />
      <FacilitySpacePanel
        spaces={facility.spaces}
        selectedSpaceId={facility.selectedSpaceId}
        isReady={facility.isReady}
        isHatchPending={facility.isHatchPending}
        onStartHatch={facility.startSpaceHatch}
        onSelectSpace={facility.selectSpace}
      />
    </section>

    <ViewerHost hostRef={facility.hostRef} />

    <FacilitySpaceDialog
      open={facility.isDialogOpen}
      draft={facility.draft}
      onDraftChange={facility.setDraft}
      onCancel={facility.cancelDialog}
      onSave={facility.saveSpace}
    />
  </main>;
}
11. App.tsx

12. App.css

Use an operations layout: space inventory on the left, floor plan on the right, and modal metadata editor.

css
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f6f8; color: #17202d; }
button, input, textarea { font: inherit; }
.facility-workspace { display: grid; grid-template-columns: minmax(320px, 430px) minmax(0, 1fr); min-height: 100vh; }
.sidebar { display: flex; flex-direction: column; gap: 16px; padding: 22px; border-right: 1px solid #d7dee8; background: #ffffff; }
.status-bar, .space-panel, .space-card, .space-dialog { border: 1px solid #d7dee8; border-radius: 8px; background: #ffffff; }
.status-bar { display: grid; gap: 12px; padding: 16px; }
.status-bar span { color: #536174; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.status-bar p { margin: 4px 0 0; }
.status-bar dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 0; }
.status-bar dl div { border-radius: 6px; padding: 10px; background: #eef2f6; }
.status-bar dt { color: #536174; font-size: 0.74rem; font-weight: 800; text-transform: uppercase; }
.status-bar dd { margin: 2px 0 0; font-size: 1.4rem; font-weight: 800; }
.space-panel { display: grid; gap: 12px; padding: 14px; }
.primary-action, .secondary-action { min-height: 38px; border-radius: 6px; padding: 0 12px; cursor: pointer; font-weight: 800; }
.primary-action { border: 0; background: #15803d; color: #ffffff; }
.secondary-action { border: 1px solid #c6d0db; background: #ffffff; color: #17202d; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.space-list { display: grid; gap: 10px; }
.empty-state { margin: 0; border: 1px dashed #c6d0db; border-radius: 8px; padding: 28px 14px; color: #536174; text-align: center; }
.space-card { display: grid; gap: 4px; width: 100%; padding: 12px; background: #f8fafc; color: inherit; cursor: pointer; text-align: left; }
.space-card.selected { border-color: #15803d; box-shadow: 0 0 0 1px #15803d; }
.space-card span, .space-card small { color: #536174; }
.viewer-shell { min-width: 0; padding: 18px; }
.viewer-host { width: 100%; height: calc(100vh - 36px); border: 1px solid #c6d0db; border-radius: 8px; background: #1c2430; }
.dialog-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 18px; background: rgba(15, 23, 42, 0.45); }
.space-dialog { display: grid; width: min(720px, 100%); max-height: 90vh; overflow: auto; gap: 14px; padding: 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); }
.space-dialog header span { color: #536174; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.space-dialog h2, .space-dialog p { margin: 4px 0 0; }
.space-dialog label { display: grid; gap: 6px; color: #536174; font-size: 0.88rem; font-weight: 700; }
.space-dialog input, .space-dialog textarea { width: 100%; border: 1px solid #c6d0db; border-radius: 6px; padding: 9px 10px; color: #17202d; }
.field-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.space-dialog footer { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 900px) { .facility-workspace { grid-template-columns: 1fr; } .sidebar { border-right: 0; border-bottom: 1px solid #d7dee8; } .viewer-host { height: 68vh; } }
@media (max-width: 560px) { .sidebar, .viewer-shell { padding: 14px; } .field-row { grid-template-columns: 1fr; } .space-dialog footer { display: grid; } }
12. App.css

13. Verification Checklist

Verify the workflow against real NPM Viewer annotation events.

  • The floor plan opens through viewer.documents.open(...) before Add space is enabled.
  • Add space calls viewer.tools.set(...) with SHAPE_POLYGON styling.
  • The metadata dialog opens only after viewer.annotations.on("created", ...) returns an annotation ID.
  • Save creates a facility space linked to the polygon annotation ID.
  • Selecting a saved space calls viewer.annotations.select(...).
  • Selecting a hatch in Canvas opens the matching space metadata dialog.
  • Updating metadata does not create duplicate room records.
  • The Viewer is destroyed and annotation listeners are removed on unmount.