Skip to content

Commit 61a2e72

Browse files
committed
callibration app working
1 parent ea30ce6 commit 61a2e72

14 files changed

Lines changed: 1084 additions & 54 deletions

File tree

apps/admin/src/pages/Calibration.tsx

Lines changed: 231 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef, useMemo, type ComponentType } from 'react';
1+
import { useState, useEffect, useRef, useMemo, useCallback, type ComponentType } from 'react';
22
import { useParams, useNavigate } from 'react-router-dom';
33
import { Stage, Layer, Image as KonvaImage, Line, Circle, Arrow, Group, Text } from 'react-konva';
44
import Draggable from 'react-draggable';
@@ -13,10 +13,20 @@ const ArrowComponent = Arrow as unknown as ComponentType<any>;
1313
const GroupComponent = Group as unknown as ComponentType<any>;
1414
const TextComponent = Text as unknown as ComponentType<any>;
1515
const DraggableComponent = Draggable as unknown as ComponentType<any>;
16-
import { fetchFloorFeatures, saveFloorFeatures, fetchFloor } from '../services/api';
16+
import {
17+
fetchFloorFeatures,
18+
saveFloorFeatures,
19+
fetchFloor,
20+
fetchCalibrationSessions,
21+
type CalibrationSample,
22+
type CalibrationSession,
23+
} from '../services/api';
1724
import CalibrationSidebar from './Calibration/CalibrationSidebar';
1825
import './Calibration/CalibrationSidebar.css';
1926

27+
const CALIBRATION_API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:4000/api';
28+
const CALIBRATION_EVENTS_URL = `${CALIBRATION_API_BASE}/calibration/events`;
29+
2030
interface ControlPoint {
2131
vertexIndex: number;
2232
lat: number;
@@ -32,6 +42,14 @@ interface DoorPoint {
3242

3343
type CalibrationStep = 'select' | 'tutorial' | 'door-setup' | 'calibrating' | 'complete';
3444
type CalibrationWalkPhase = 'select-waypoint' | 'walking' | 'confirm';
45+
type CalibrationEventType = 'session-started' | 'session-stopped' | 'sample-recorded' | 'corner-recorded';
46+
47+
interface CalibrationStreamEvent<TPayload = unknown> {
48+
type: CalibrationEventType;
49+
sessionId: string;
50+
payload: TPayload;
51+
timestamp: number;
52+
}
3553

3654
export default function Calibration() {
3755
const { floorId, featureId } = useParams<{ floorId: string; featureId: string }>();
@@ -55,6 +73,14 @@ export default function Calibration() {
5573
const [isSaving, setIsSaving] = useState(false);
5674
const [activeWaypointIdx, setActiveWaypointIdx] = useState<number | null>(null);
5775
const [walkPhase, setWalkPhase] = useState<CalibrationWalkPhase>('select-waypoint');
76+
const [remoteSessionId, setRemoteSessionId] = useState<string | null>(null);
77+
const [calibrationLinkStatus, setCalibrationLinkStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle');
78+
const [calibrationLog, setCalibrationLog] = useState<string[]>([]);
79+
const [latestPhoneSample, setLatestPhoneSample] = useState<CalibrationSample | null>(null);
80+
const appendCalibrationLog = useCallback((entry: string) => {
81+
setCalibrationLog((prev) => [entry, ...prev].slice(0, 40));
82+
}, []);
83+
const handleRecordPointRef = useRef<(sampleOverride?: { lat: number; lng: number; accuracy: number }) => void>();
5884
const [doorSelectionError, setDoorSelectionError] = useState<string | null>(null);
5985

6086
// GPS State
@@ -93,6 +119,32 @@ export default function Calibration() {
93119
setStageSize(computeStageDimensions());
94120
}, [step, selectedFeature]);
95121

122+
useEffect(() => {
123+
let isSubscribed = true;
124+
fetchCalibrationSessions()
125+
.then((sessions: CalibrationSession[]) => {
126+
if (!isSubscribed || sessions.length === 0) return;
127+
const activeSession = sessions.find((session) => !session.stoppedAt);
128+
if (!activeSession) return;
129+
setRemoteSessionId(activeSession.sessionId);
130+
appendCalibrationLog(`Session ${activeSession.sessionId} resumed`);
131+
if (activeSession.lastSample) {
132+
setLatestPhoneSample(activeSession.lastSample);
133+
setCurrentGPS({
134+
lat: activeSession.lastSample.lat,
135+
lng: activeSession.lastSample.lng,
136+
accuracy: activeSession.lastSample.accuracy,
137+
});
138+
}
139+
})
140+
.catch((error) => {
141+
console.error('Failed to fetch calibration sessions', error);
142+
});
143+
return () => {
144+
isSubscribed = false;
145+
};
146+
}, []);
147+
96148
const loadFloorData = async () => {
97149
try {
98150
const [floorData, featuresData] = await Promise.all([
@@ -113,6 +165,90 @@ export default function Calibration() {
113165
const allFeaturesData = featuresData.features;
114166
const found = allFeaturesData.find((f: any) => f.properties.id === featureId);
115167
if (found) {
168+
169+
useEffect(() => {
170+
setCalibrationLinkStatus('connecting');
171+
const source = new EventSource(CALIBRATION_EVENTS_URL);
172+
173+
const parseEvent = <TPayload,>(event: MessageEvent<string>): CalibrationStreamEvent<TPayload> | null => {
174+
try {
175+
return JSON.parse(event.data) as CalibrationStreamEvent<TPayload>;
176+
} catch (error) {
177+
console.warn('Failed to parse calibration event', error);
178+
return null;
179+
}
180+
};
181+
182+
source.onopen = () => {
183+
setCalibrationLinkStatus('open');
184+
appendCalibrationLog('Calibration stream connected');
185+
};
186+
187+
source.onerror = () => {
188+
setCalibrationLinkStatus('error');
189+
appendCalibrationLog('Calibration stream error');
190+
};
191+
192+
const handleSample = (event: MessageEvent<string>) => {
193+
const data = parseEvent<CalibrationSample>(event);
194+
if (!data || !data.payload) return;
195+
setRemoteSessionId((prev) => prev ?? data.sessionId);
196+
setLatestPhoneSample(data.payload);
197+
setCurrentGPS({
198+
lat: data.payload.lat,
199+
lng: data.payload.lng,
200+
accuracy: data.payload.accuracy,
201+
});
202+
};
203+
204+
const handleCorner = (event: MessageEvent<string>) => {
205+
const data = parseEvent<{ sample: CalibrationSample; recordedAt: number }>(event);
206+
if (!data || !data.payload) return;
207+
setRemoteSessionId((prev) => prev ?? data.sessionId);
208+
appendCalibrationLog(`Corner recorded (${data.sessionId})`);
209+
handleRecordPointRef.current?.({
210+
lat: data.payload.sample.lat,
211+
lng: data.payload.sample.lng,
212+
accuracy: data.payload.sample.accuracy,
213+
});
214+
};
215+
216+
const handleSessionStarted = (event: MessageEvent<string>) => {
217+
const data = parseEvent<CalibrationSession>(event);
218+
if (!data || !data.payload) return;
219+
setRemoteSessionId(data.sessionId);
220+
appendCalibrationLog(`Session ${data.sessionId} started`);
221+
if (data.payload.lastSample) {
222+
setLatestPhoneSample(data.payload.lastSample);
223+
setCurrentGPS({
224+
lat: data.payload.lastSample.lat,
225+
lng: data.payload.lastSample.lng,
226+
accuracy: data.payload.lastSample.accuracy,
227+
});
228+
}
229+
};
230+
231+
const handleSessionStopped = (event: MessageEvent<string>) => {
232+
const data = parseEvent<{ stoppedAt: number; lastSample?: CalibrationSample }>(event);
233+
if (!data) return;
234+
appendCalibrationLog(`Session ${data.sessionId} stopped`);
235+
setRemoteSessionId((current) => (current === data.sessionId ? null : current));
236+
};
237+
238+
source.addEventListener('sample-recorded', handleSample);
239+
source.addEventListener('corner-recorded', handleCorner);
240+
source.addEventListener('session-started', handleSessionStarted);
241+
source.addEventListener('session-stopped', handleSessionStopped);
242+
243+
return () => {
244+
source.removeEventListener('sample-recorded', handleSample);
245+
source.removeEventListener('corner-recorded', handleCorner);
246+
source.removeEventListener('session-started', handleSessionStarted);
247+
source.removeEventListener('session-stopped', handleSessionStopped);
248+
source.close();
249+
setCalibrationLinkStatus('idle');
250+
};
251+
}, [appendCalibrationLog]);
116252
selectFeature(found, allFeaturesData);
117253
}
118254
}
@@ -253,29 +389,6 @@ export default function Calibration() {
253389
setStep('calibrating');
254390
};
255391

256-
const handleRecordPoint = () => {
257-
if (!currentGPS || perimeterSequence.length === 0) return;
258-
259-
const sequenceIndex = perimeterSequence[currentVertexIndex];
260-
if (sequenceIndex === undefined) return;
261-
262-
const newPoint: ControlPoint = {
263-
vertexIndex: sequenceIndex,
264-
lat: currentGPS.lat,
265-
lng: currentGPS.lng,
266-
accuracy: currentGPS.accuracy
267-
};
268-
269-
const updatedPoints = [...controlPoints, newPoint];
270-
setControlPoints(updatedPoints);
271-
272-
if (updatedPoints.length >= perimeterSequence.length) {
273-
saveCalibration(updatedPoints);
274-
} else {
275-
setCurrentVertexIndex(prev => Math.min(prev + 1, perimeterSequence.length - 1));
276-
}
277-
};
278-
279392
const saveCalibration = async (finalPoints: ControlPoint[]) => {
280393
setIsSaving(true);
281394
try {
@@ -363,6 +476,26 @@ export default function Calibration() {
363476
return Array.from({ length: polygonPoints.length }, (_, offset) => (activeWaypointIdx + offset) % polygonPoints.length);
364477
}, [activeWaypointIdx, polygonPoints]);
365478

479+
const phoneLinkStatusLabel = useMemo(() => {
480+
if (remoteSessionId) {
481+
const accuracyText = latestPhoneSample && Number.isFinite(latestPhoneSample.accuracy)
482+
? ` • ±${latestPhoneSample.accuracy.toFixed(1)}m`
483+
: '';
484+
return `Active (${remoteSessionId.slice(-6)})${accuracyText}`;
485+
}
486+
if (calibrationLinkStatus === 'open') return 'Waiting for session';
487+
if (calibrationLinkStatus === 'connecting') return 'Connecting…';
488+
if (calibrationLinkStatus === 'error') return 'Connection lost';
489+
return 'Idle';
490+
}, [calibrationLinkStatus, latestPhoneSample, remoteSessionId]);
491+
492+
const phoneLinkColor = useMemo(() => {
493+
if (remoteSessionId) return '#28a745';
494+
if (calibrationLinkStatus === 'error') return '#ff5252';
495+
if (calibrationLinkStatus === 'connecting') return '#ff9800';
496+
return '#aaaaaa';
497+
}, [calibrationLinkStatus, remoteSessionId]);
498+
366499
useEffect(() => {
367500
if (perimeterSequence.length === 0) {
368501
setCurrentVertexIndex(0);
@@ -380,6 +513,45 @@ export default function Calibration() {
380513
setControlPoints([]);
381514
};
382515

516+
const handleRecordPoint = useCallback((sampleOverride?: { lat: number; lng: number; accuracy: number }) => {
517+
if (step !== 'calibrating') return;
518+
const source = sampleOverride ?? currentGPS;
519+
if (!source || perimeterSequence.length === 0) return;
520+
521+
setControlPoints((previous) => {
522+
const nextIndex = previous.length;
523+
const sequenceIndex = perimeterSequence[nextIndex];
524+
if (sequenceIndex === undefined) {
525+
return previous;
526+
}
527+
528+
if (previous.some((point) => point.vertexIndex === sequenceIndex)) {
529+
return previous;
530+
}
531+
532+
const newPoint: ControlPoint = {
533+
vertexIndex: sequenceIndex,
534+
lat: source.lat,
535+
lng: source.lng,
536+
accuracy: source.accuracy,
537+
};
538+
539+
const updatedPoints = [...previous, newPoint];
540+
541+
if (updatedPoints.length >= perimeterSequence.length) {
542+
saveCalibration(updatedPoints);
543+
} else {
544+
setCurrentVertexIndex(updatedPoints.length);
545+
}
546+
547+
return updatedPoints;
548+
});
549+
}, [currentGPS, perimeterSequence, saveCalibration, step]);
550+
551+
useEffect(() => {
552+
handleRecordPointRef.current = handleRecordPoint;
553+
}, [handleRecordPoint]);
554+
383555
const sidebarDefaultPosition = useMemo(() => ({
384556
x: 24,
385557
y: Math.max(48, stageSize.height / 2 - 160)
@@ -575,6 +747,13 @@ export default function Calibration() {
575747
}}>
576748
GPS: ±{currentGPS?.accuracy.toFixed(1) || '?'}m
577749
</div>
750+
<div style={{
751+
fontSize: '12px',
752+
color: phoneLinkColor,
753+
marginTop: '6px'
754+
}}>
755+
Phone Link: {phoneLinkStatusLabel}
756+
</div>
578757
</div>
579758
<div style={{ marginTop: '12px' }}>
580759
<div style={{ fontSize: '12px', letterSpacing: '0.08em', color: '#aaaaaa', marginBottom: '8px' }}>
@@ -754,6 +933,7 @@ export default function Calibration() {
754933
)}
755934
<div style={{ textAlign: 'center', marginTop: '10px', fontSize: '12px', color: '#aaa' }}>
756935
{!currentGPS ? '⚠️ Waiting for GPS signal' : `GPS: ${currentGPS.accuracy.toFixed(1)}m`}
936+
{remoteSessionId ? ` • Session ${remoteSessionId.slice(-6)}` : ''}
757937
</div>
758938
</div>
759939
</div>
@@ -825,6 +1005,9 @@ export default function Calibration() {
8251005
GPS: ±{currentGPS?.accuracy.toFixed(1) || '?'}m
8261006
</div>
8271007
</div>
1008+
<div style={{ fontSize: '12px', color: phoneLinkColor }}>
1009+
Phone Link: {phoneLinkStatusLabel}
1010+
</div>
8281011

8291012
<div style={{
8301013
width: '100%',
@@ -1025,11 +1208,33 @@ export default function Calibration() {
10251208
</div>
10261209
))}
10271210
</div>
1211+
1212+
<div style={{
1213+
position: 'absolute',
1214+
bottom: '20px',
1215+
right: '20px',
1216+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
1217+
color: 'white',
1218+
padding: '15px',
1219+
borderRadius: '12px',
1220+
maxWidth: '220px'
1221+
}}>
1222+
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '10px' }}>PHONE STREAM</div>
1223+
{calibrationLog.length === 0 ? (
1224+
<div style={{ fontSize: '12px', color: '#94a3b8' }}>Waiting for events…</div>
1225+
) : (
1226+
calibrationLog.slice(0, 4).map((entry, idx) => (
1227+
<div key={`${entry}-${idx}`} style={{ fontSize: '12px', color: '#cbd5f5', marginBottom: idx < 3 ? '6px' : 0 }}>
1228+
{entry}
1229+
</div>
1230+
))
1231+
)}
1232+
</div>
10281233
</div>
10291234

10301235
<div style={{ padding: '20px', backgroundColor: '#1a1a1a', borderTop: '1px solid #333' }}>
10311236
<button
1032-
onClick={handleRecordPoint}
1237+
onClick={() => handleRecordPoint()}
10331238
disabled={!currentGPS || isSaving || perimeterSequence.length === 0}
10341239
style={{
10351240
width: '100%',

apps/admin/src/services/api.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,32 @@ export const getCalibrationStats = async (floorId: string) => {
175175
const response = await api.get(`/floors/${floorId}/calibration-stats`);
176176
return response.data.data;
177177
};
178+
179+
export type CalibrationSample = {
180+
lat: number;
181+
lng: number;
182+
accuracy: number;
183+
heading?: number | null;
184+
speed?: number | null;
185+
timestamp: number;
186+
};
187+
188+
export type CalibrationCornerRecord = {
189+
sample: CalibrationSample;
190+
recordedAt: number;
191+
};
192+
193+
export type CalibrationSession = {
194+
sessionId: string;
195+
startedAt: number;
196+
stoppedAt?: number;
197+
samples: CalibrationSample[];
198+
corners: CalibrationCornerRecord[];
199+
meta?: Record<string, unknown>;
200+
lastSample?: CalibrationSample;
201+
};
202+
203+
export const fetchCalibrationSessions = async (): Promise<CalibrationSession[]> => {
204+
const response = await api.get('/calibration/sessions');
205+
return response.data.data as CalibrationSession[];
206+
};

0 commit comments

Comments
 (0)