1- import { useState , useEffect , useRef , useMemo , type ComponentType } from 'react' ;
1+ import { useState , useEffect , useRef , useMemo , useCallback , type ComponentType } from 'react' ;
22import { useParams , useNavigate } from 'react-router-dom' ;
33import { Stage , Layer , Image as KonvaImage , Line , Circle , Arrow , Group , Text } from 'react-konva' ;
44import Draggable from 'react-draggable' ;
@@ -13,10 +13,20 @@ const ArrowComponent = Arrow as unknown as ComponentType<any>;
1313const GroupComponent = Group as unknown as ComponentType < any > ;
1414const TextComponent = Text as unknown as ComponentType < any > ;
1515const 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' ;
1724import CalibrationSidebar from './Calibration/CalibrationSidebar' ;
1825import './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+
2030interface ControlPoint {
2131 vertexIndex : number ;
2232 lat : number ;
@@ -32,6 +42,14 @@ interface DoorPoint {
3242
3343type CalibrationStep = 'select' | 'tutorial' | 'door-setup' | 'calibrating' | 'complete' ;
3444type 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
3654export 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%' ,
0 commit comments