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.
Create the React app and install the Rasterex Viewer package.
npm create vite@latest facility-management-react -- --template react-ts cd facility-management-react npm install @rasterex/viewer npm run dev
Split the Viewer lifecycle, room metadata state, side panel, dialog, and viewer host into separate files.
src/
App.tsx
App.css
config/
facilityConfig.ts
types/
facility.ts
hooks/
useFacilityManagementViewer.ts
components/
FacilitySpaceDialog.tsx
FacilitySpacePanel.tsx
FacilityStatusBar.tsx
ViewerHost.tsxSet a floor-plan drawing URL that Canvas can load.
VITE_FACILITY_PLAN_URL=https://files.example.com/floor-plan.pdf VITE_FACILITY_PLAN_NAME=floor-plan.pdf
Create the room metadata, draft, Viewer state, and annotation event types.
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;
}Centralize floor-plan settings, default room metadata, and polygon hatch styling.
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,
};Mount the NPM Viewer once, activate polygon space mapping, capture created annotation IDs, and synchronize room selection.
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;
}Show the workflow message and room totals above the space list.
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>;
}Render the saved room list and start polygon hatch mapping from the side panel.
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>;
}Edit room metadata after the polygon annotation is created or when an existing space is selected.
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>;
}Render the NPM Viewer mount point.
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>;
}Compose the Viewer, space list, status summary, and metadata dialog.
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>;
}Use an operations layout: space inventory on the left, floor plan on the right, and modal metadata editor.
* { 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; } }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(...)withSHAPE_POLYGONstyling. - 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.
