astro-archify 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -344,7 +344,6 @@ export function cleanFlowProblems({
344
344
  diagramType,
345
345
  relationCollection,
346
346
  obstacleKind,
347
- profile,
348
347
  clearance = 2,
349
348
  routeHint = 'adjust fromSide/toSide, set route/via or channel coordinates, or move the obstacle'
350
349
  }) {
@@ -399,6 +398,85 @@ export function cleanFlowProblems({
399
398
  return problems;
400
399
  }
401
400
 
401
+ // Build a read-only analysis copy of a polyline with straight-through
402
+ // waypoints removed. A waypoint on a forward-collinear run is not a visual
403
+ // endpoint, so treating it as one would hide a proper X that lands exactly on
404
+ // that waypoint. Reversals and real bends stay split: their shared point can
405
+ // still be an authored touch rather than a crossing. The source points are
406
+ // retained on every merged segment so diagnostics can name the authored
407
+ // segment that contains a hit without changing rendered/receipt geometry.
408
+ export function forwardCollinearAnalysisSegments(points) {
409
+ const segments = [];
410
+ for (let segmentIndex = 0; segmentIndex < asArray(points).length - 1; segmentIndex += 1) {
411
+ const authoredStart = points[segmentIndex];
412
+ const authoredEnd = points[segmentIndex + 1];
413
+ const start = Array.isArray(authoredStart) ? [...authoredStart] : authoredStart;
414
+ const end = Array.isArray(authoredEnd) ? [...authoredEnd] : authoredEnd;
415
+ const sourceSegment = { start, end, segmentIndex };
416
+ const previous = segments.at(-1);
417
+ if (previous && segmentsContinueForward(previous.start, previous.end, start, end)) {
418
+ previous.end = end;
419
+ previous.sourceSegments.push(sourceSegment);
420
+ continue;
421
+ }
422
+ segments.push({
423
+ start,
424
+ end,
425
+ segmentIndex,
426
+ sourceSegments: [sourceSegment],
427
+ });
428
+ }
429
+ return segments;
430
+ }
431
+
432
+ export function sourceSegmentIndexAtPoint(segment, point) {
433
+ const source = asArray(segment?.sourceSegments).find(({ start, end }) => (
434
+ pointLiesOnSegment(point, start, end)
435
+ ));
436
+ return source?.segmentIndex ?? segment?.segmentIndex ?? 0;
437
+ }
438
+
439
+ function authoredAnalysisSegments(points) {
440
+ return asArray(points).slice(0, -1).map((start, segmentIndex) => ({
441
+ start,
442
+ end: points[segmentIndex + 1],
443
+ segmentIndex,
444
+ sourceSegments: [{ start, end: points[segmentIndex + 1], segmentIndex }],
445
+ }));
446
+ }
447
+
448
+ function segmentsContinueForward(firstStart, firstEnd, secondStart, secondEnd) {
449
+ if (![firstStart, firstEnd, secondStart, secondEnd].every((point) => (
450
+ Array.isArray(point) && point.length === 2 && isFinitePoint(...point)
451
+ ))) return false;
452
+ const epsilon = 0.0001;
453
+ if (Math.abs(firstEnd[0] - secondStart[0]) > epsilon
454
+ || Math.abs(firstEnd[1] - secondStart[1]) > epsilon) return false;
455
+ const firstVector = [firstEnd[0] - firstStart[0], firstEnd[1] - firstStart[1]];
456
+ const secondVector = [secondEnd[0] - secondStart[0], secondEnd[1] - secondStart[1]];
457
+ const firstLength = Math.hypot(...firstVector);
458
+ const secondLength = Math.hypot(...secondVector);
459
+ if (firstLength <= epsilon || secondLength <= epsilon) return false;
460
+ const cross = firstVector[0] * secondVector[1] - firstVector[1] * secondVector[0];
461
+ if (Math.abs(cross) > epsilon) return false;
462
+ const dot = firstVector[0] * secondVector[0] + firstVector[1] * secondVector[1];
463
+ return dot > epsilon;
464
+ }
465
+
466
+ function pointLiesOnSegment(point, start, end) {
467
+ if (![point, start, end].every((candidate) => (
468
+ Array.isArray(candidate) && candidate.length === 2 && isFinitePoint(...candidate)
469
+ ))) return false;
470
+ const epsilon = 0.0001;
471
+ const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
472
+ if (length <= epsilon) return Math.hypot(point[0] - start[0], point[1] - start[1]) <= epsilon;
473
+ if (Math.abs(crossProduct(start, end, point)) > epsilon * length) return false;
474
+ return point[0] >= Math.min(start[0], end[0]) - epsilon
475
+ && point[0] <= Math.max(start[0], end[0]) + epsilon
476
+ && point[1] >= Math.min(start[1], end[1]) - epsilon
477
+ && point[1] <= Math.max(start[1], end[1]) + epsilon;
478
+ }
479
+
402
480
  // Reject only a proper interior X between relationships that share no semantic
403
481
  // endpoint. Endpoint touches, branch/merge ports, and collinear shared
404
482
  // corridors are intentionally outside this contract because geometry alone
@@ -410,17 +488,24 @@ export function cleanCrossingProblems({
410
488
  diagramType,
411
489
  relationCollection,
412
490
  profile = 'standard',
491
+ profileIsAuthoritative = false,
492
+ mergeForwardCollinearWaypoints = false,
413
493
  routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
414
494
  }) {
415
- const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
416
- const activeProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
417
- if (activeProfile !== 'showcase') return [];
495
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
418
496
  const routed = asArray(relations).map((relation, index) => {
419
497
  if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
420
498
  const points = pathFor(relation)?.points;
421
499
  if (!Array.isArray(points) || points.length < 2) return null;
422
500
  if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) return null;
423
- return { relation, index, points };
501
+ return {
502
+ relation,
503
+ index,
504
+ points,
505
+ analysisSegments: mergeForwardCollinearWaypoints
506
+ ? forwardCollinearAnalysisSegments(points)
507
+ : authoredAnalysisSegments(points),
508
+ };
424
509
  }).filter(Boolean);
425
510
  const problems = [];
426
511
 
@@ -431,16 +516,21 @@ export function cleanCrossingProblems({
431
516
  if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
432
517
 
433
518
  let hit = null;
434
- for (let leftSegment = 0; leftSegment < left.points.length - 1 && !hit; leftSegment += 1) {
435
- for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
519
+ for (const leftSegment of left.analysisSegments) {
520
+ if (hit) break;
521
+ for (const rightSegment of right.analysisSegments) {
436
522
  const point = properSegmentIntersection(
437
- left.points[leftSegment],
438
- left.points[leftSegment + 1],
439
- right.points[rightSegment],
440
- right.points[rightSegment + 1]
523
+ leftSegment.start,
524
+ leftSegment.end,
525
+ rightSegment.start,
526
+ rightSegment.end
441
527
  );
442
528
  if (point) {
443
- hit = { point, leftSegment, rightSegment };
529
+ hit = {
530
+ point,
531
+ leftSegment: sourceSegmentIndexAtPoint(leftSegment, point),
532
+ rightSegment: sourceSegmentIndexAtPoint(rightSegment, point),
533
+ };
444
534
  break;
445
535
  }
446
536
  }
@@ -536,16 +626,12 @@ export function cleanAmbiguousCorridorProblems({
536
626
  diagramType,
537
627
  relationCollection,
538
628
  profile = 'standard',
629
+ profileIsAuthoritative = false,
539
630
  routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
540
631
  minOverlapPx = 8,
541
632
  }) {
542
- const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
543
- if (requestedProfile !== 'showcase') return [];
544
- const routedRelations = asArray(relations).map((relation, relationIndex) => {
545
- if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
546
- if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
547
- return { relation, relationIndex, points: pathFor(relation)?.points };
548
- }).filter(Boolean);
633
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
634
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
549
635
 
550
636
  return collectAmbiguousCorridors({ routedRelations, minOverlapPx }).map((hit) => {
551
637
  const describe = ({ relation, relationIndex }) => {
@@ -634,14 +720,11 @@ export function cleanBorderRunProblems({
634
720
  diagramType,
635
721
  relationCollection,
636
722
  profile,
723
+ profileIsAuthoritative = false,
637
724
  routeHint = 'adjust route/via or channel coordinates so the relationship crosses the frame perpendicularly through a clear opening'
638
725
  }) {
639
- if (!process.env.ARCHIFY_QUALITY_PROFILE && !profile) return [];
640
- const routedRelations = asArray(relations).map((relation, relationIndex) => {
641
- if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
642
- if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
643
- return { relation, relationIndex, points: pathFor(relation)?.points };
644
- }).filter(Boolean);
726
+ if (!qualityProfileForGate(profile, profileIsAuthoritative)) return [];
727
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
645
728
  return collectBorderRuns({ routedRelations, frames }).map((hit) => {
646
729
  const relation = hit.relation || {};
647
730
  const relationId = relation.id ? ` id "${relation.id}"` : '';
@@ -779,17 +862,13 @@ export function cleanRouteRhythmProblems({
779
862
  diagramType,
780
863
  relationCollection,
781
864
  profile,
865
+ profileIsAuthoritative = false,
782
866
  routeHint = 'move the channel/via point to remove the cramped turn or give the route more corridor space',
783
867
  interiorSegmentPx = 16,
784
868
  microSegmentPx = 8,
785
869
  }) {
786
- const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
787
- if (requestedProfile !== 'showcase') return [];
788
- const routedRelations = asArray(relations).map((relation, relationIndex) => {
789
- if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
790
- if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
791
- return { relation, relationIndex, points: pathFor(relation)?.points };
792
- }).filter(Boolean);
870
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
871
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
793
872
  return collectRouteRhythmIssues({ routedRelations, interiorSegmentPx, microSegmentPx }).map((hit) => {
794
873
  const relation = hit.relation || {};
795
874
  const relationId = relation.id ? ` id "${relation.id}"` : '';
@@ -827,16 +906,12 @@ export function cleanLabelRouteClearanceProblems({
827
906
  diagramType,
828
907
  relationCollection,
829
908
  profile,
909
+ profileIsAuthoritative = false,
830
910
  threshold = 4,
831
911
  routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other relationship route/via/channel',
832
912
  }) {
833
- const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
834
- if (requestedProfile !== 'showcase') return [];
835
- const routedRelations = asArray(relations).map((relation, relationIndex) => {
836
- if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
837
- if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
838
- return { relation, relationIndex, points: pathFor(relation)?.points };
839
- }).filter(Boolean);
913
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
914
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
840
915
  return collectLabelRouteClearance({ labels, routedRelations, threshold }).map((hit) => {
841
916
  const describe = (relation, relationIndex) => {
842
917
  const relationId = relation?.id ? ` id "${relation.id}"` : '';
@@ -868,6 +943,20 @@ export function cleanLabelRouteClearanceProblems({
868
943
  });
869
944
  }
870
945
 
946
+ function qualityProfileForGate(profile, profileIsAuthoritative) {
947
+ return profileIsAuthoritative
948
+ ? profile
949
+ : process.env.ARCHIFY_QUALITY_PROFILE || profile;
950
+ }
951
+
952
+ function collectEligibleRoutedRelations({ relations, endpointIds, pathFor }) {
953
+ return asArray(relations).map((relation, relationIndex) => {
954
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
955
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
956
+ return { relation, relationIndex, points: pathFor(relation)?.points };
957
+ }).filter(Boolean);
958
+ }
959
+
871
960
  function segmentPosition(index, segmentCount) {
872
961
  if (index === 0) return 'source-stub';
873
962
  if (index === segmentCount - 1) return 'target-stub';
@@ -1319,7 +1408,7 @@ export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKin
1319
1408
  export function suggestLabelPairFix(a, b) {
1320
1409
  return [
1321
1410
  ` "${a.label}" ${formatRect(a)}; "${b.label}" ${formatRect(b)}`,
1322
- ' Suggested fix: add labelDy +24 on one edge, adjust labelDx, or remove one label',
1411
+ ' Suggested fix: adjust labelDx/labelDy/labelSegment, or route one relationship through a separate corridor',
1323
1412
  ].join('\n');
1324
1413
  }
1325
1414