@@ -756,6 +756,30 @@ function isDangling(gaps: EndpointHeldGaps): boolean {
756756 return gaps . detachedFraction >= CONNECTOR_HELD_DETACH_FRAC ;
757757}
758758
759+ // Gauge-indicator geometry: the anchored end pivots near a ring/arc centre and
760+ // the loose end extends radially outward. That is a needle/pointer (owned by a
761+ // separate gauge check), not a broken node connector. A defective radial
762+ // connector points the other way — anchored on a peripheral node, loose end
763+ // drifting toward the centre — so this only excludes true centre-pivot pointers.
764+ const GAUGE_HUB_CENTRE_FRACTION = 0.25 ;
765+
766+ function isGaugeIndicator (
767+ anchored : { x : number ; y : number } ,
768+ dangling : { x : number ; y : number } ,
769+ nodes : ConnectorNodeBox [ ] ,
770+ ) : boolean {
771+ for ( const node of nodes ) {
772+ if ( ! node . ring ) continue ;
773+ const cx = ( node . left + node . right ) / 2 ;
774+ const cy = ( node . top + node . bottom ) / 2 ;
775+ const size = Math . max ( node . right - node . left , node . bottom - node . top ) ;
776+ const dAnchored = Math . hypot ( anchored . x - cx , anchored . y - cy ) ;
777+ const dDangling = Math . hypot ( dangling . x - cx , dangling . y - cy ) ;
778+ if ( dAnchored <= GAUGE_HUB_CENTRE_FRACTION * size && dDangling > dAnchored ) return true ;
779+ }
780+ return false ;
781+ }
782+
759783function connectorDetachFinding (
760784 selector : string ,
761785 point : { x : number ; y : number } ,
@@ -803,6 +827,49 @@ function connectorDetachFinding(
803827 * lines to a nearby label do not); and a selector that aliases multiple
804828 * connectors in one frame is dropped.
805829 */
830+ interface DanglePick {
831+ anchored : { x : number ; y : number } ;
832+ dangling : { x : number ; y : number } ;
833+ gap : number ;
834+ }
835+
836+ /** If exactly one endpoint is anchored and the other persistently dangles, name
837+ * the anchored/dangling points and the dangling gap; else null. */
838+ function danglingEndpoint (
839+ last : ConnectorObservation ,
840+ gapsA : EndpointHeldGaps ,
841+ gapsB : EndpointHeldGaps ,
842+ ) : DanglePick | null {
843+ const a = { x : last . ax , y : last . ay } ;
844+ const b = { x : last . bx , y : last . by } ;
845+ if ( isAnchored ( gapsA ) && isDangling ( gapsB ) && ! isAnchored ( gapsB ) ) {
846+ return { anchored : a , dangling : b , gap : gapsB . danglingGap } ;
847+ }
848+ if ( isAnchored ( gapsB ) && isDangling ( gapsA ) && ! isAnchored ( gapsA ) ) {
849+ return { anchored : b , dangling : a , gap : gapsA . danglingGap } ;
850+ }
851+ return null ;
852+ }
853+
854+ /** One connector's held trajectory → a finding, or null. A finding needs one
855+ * endpoint anchored to a node and the other persistently in empty space, and is
856+ * suppressed for gauge-indicator geometry (see isGaugeIndicator). */
857+ function connectorGroupFinding (
858+ selector : string ,
859+ group : ConnectorObservation [ ] ,
860+ holdStart : number ,
861+ detachThreshold : number ,
862+ ) : AnchoredLayoutIssue | null {
863+ const last = group . length >= CONNECTOR_MIN_FRAMES ? group [ group . length - 1 ] : undefined ;
864+ if ( ! last ) return null ;
865+ const gapsA = endpointHeldGaps ( group , ( o ) => ( { x : o . ax , y : o . ay } ) , holdStart , detachThreshold ) ;
866+ const gapsB = endpointHeldGaps ( group , ( o ) => ( { x : o . bx , y : o . by } ) , holdStart , detachThreshold ) ;
867+ if ( ! gapsA || ! gapsB ) return null ;
868+ const pick = danglingEndpoint ( last , gapsA , gapsB ) ;
869+ if ( ! pick || isGaugeIndicator ( pick . anchored , pick . dangling , last . nodes ) ) return null ;
870+ return connectorDetachFinding ( selector , pick . dangling , pick . gap , last . time ) ;
871+ }
872+
806873export function detectConnectorMotionDetached (
807874 frames : ConnectorFrame [ ] ,
808875 canvas : Canvas ,
@@ -816,24 +883,8 @@ export function detectConnectorMotionDetached(
816883 CONNECTOR_DETACH_VIEWPORT_FRACTION * Math . min ( canvas . width , canvas . height ) ,
817884 ) ;
818885 for ( const [ selector , group ] of groupConnectorsBySelector ( frames ) ) {
819- if ( group . length < CONNECTOR_MIN_FRAMES ) continue ;
820- const last = group [ group . length - 1 ] ;
821- if ( ! last ) continue ;
822- const pickA = ( o : ConnectorObservation ) => ( { x : o . ax , y : o . ay } ) ;
823- const pickB = ( o : ConnectorObservation ) => ( { x : o . bx , y : o . by } ) ;
824- const gapsA = endpointHeldGaps ( group , pickA , holdStart , detachThreshold ) ;
825- const gapsB = endpointHeldGaps ( group , pickB , holdStart , detachThreshold ) ;
826- if ( ! gapsA || ! gapsB ) continue ;
827- // One endpoint anchored to a node, the other persistently in empty space.
828- if ( isAnchored ( gapsA ) && isDangling ( gapsB ) && ! isAnchored ( gapsB ) ) {
829- findings . push (
830- connectorDetachFinding ( selector , { x : last . bx , y : last . by } , gapsB . danglingGap , last . time ) ,
831- ) ;
832- } else if ( isAnchored ( gapsB ) && isDangling ( gapsA ) && ! isAnchored ( gapsA ) ) {
833- findings . push (
834- connectorDetachFinding ( selector , { x : last . ax , y : last . ay } , gapsA . danglingGap , last . time ) ,
835- ) ;
836- }
886+ const finding = connectorGroupFinding ( selector , group , holdStart , detachThreshold ) ;
887+ if ( finding ) findings . push ( finding ) ;
837888 }
838889 return findings ;
839890}
0 commit comments