From 917bf2583b993fc11261d832458dbf8b208ab275 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 19:09:43 +1200 Subject: [PATCH 1/4] Update `0062_edges_from_higher_rank_to_lower_rank.yaml` diagram. --- ..._edges_from_higher_rank_to_lower_rank.yaml | 107 +++++++++++++----- .../taffy_to_svg_elements_mapper.rs | 33 +++--- 2 files changed, 97 insertions(+), 43 deletions(-) diff --git a/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml b/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml index b69f00e8..31e2e119 100644 --- a/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml +++ b/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml @@ -1,6 +1,8 @@ things: t_localhost: t_localhost_app: + t_localhost_app_web_app: {} + t_localhost_app_mediapipe: {} t_localhost_app_target: t_localhost_app_target_footage: {} t_localhost_app_target_pose: {} @@ -10,7 +12,7 @@ things: t_github_app: {} t_aws: t_aws_ecr: - t_aws_ecr_pipe: {} + t_aws_ecr_mediapipe: {} t_aws_ecr_app: t_aws_ecr_app_image_1: {} t_aws_az1: @@ -19,17 +21,29 @@ things: t_aws_az1_subnet_mgmt_bastion: {} t_aws_az1_subnet_tier_public: {} t_aws_az1_subnet_tier_private: - t_aws_az1_subnet_tier_private_lambda_pipe: {} + t_aws_az1_subnet_tier_private_lambda_mediapipe: {} t_aws_rds: t_aws_rds_tier: {} t_aws_s3: t_aws_s3_tier: t_aws_s3_tier_footage: {} t_aws_s3_tier_stills: {} + thing_names: + t_localhost: πŸ§‘πŸ»β€πŸ’» Developer machine + t_localhost_app: πŸƒπŸ»β€βž‘οΈ App + t_localhost_app_web_app: 🌐 Web app + t_localhost_app_mediapipe: πŸ“½οΈ Mediapipe app + t_localhost_app_target: "`target` (working dir)" + t_localhost_app_target_footage: footage + t_localhost_app_target_pose: pose + t_localhost_postgres: πŸ—„οΈ Postgres + t_localhost_postgres_app: πŸ—„οΈ App local database + t_github: πŸ™ GitHub + t_github_app: πŸƒπŸ»β€βž‘οΈ App t_aws: ☁️ AWS t_aws_ecr: πŸ—„οΈ ECR - t_aws_ecr_pipe: πŸ“½οΈ Pipe + t_aws_ecr_mediapipe: πŸ“½οΈ Mediapipe t_aws_ecr_app: πŸƒπŸ»β€βž‘οΈ App t_aws_ecr_app_image_1: πŸ“€ App v0.0.1 t_aws_az1: 🏒 Availability Zone 1 (e.g. ap-southeast-2a) @@ -38,30 +52,35 @@ thing_names: t_aws_az1_subnet_mgmt_bastion: πŸ’» Bastion host t_aws_az1_subnet_tier_public: πŸ•ΈοΈ Tier Public Subnet t_aws_az1_subnet_tier_private: πŸ•ΈοΈ Tier Private Subnet - t_aws_az1_subnet_tier_private_lambda_pipe: Ξ» Pipe Lambda + t_aws_az1_subnet_tier_private_lambda_mediapipe: Ξ» Mediapipe Lambda t_aws_rds: πŸ—„οΈ RDS t_aws_rds_tier: πŸ—„οΈ App tier database t_aws_s3: πŸͺ£ S3 t_aws_s3_tier: πŸͺ£ Tier Bucket t_aws_s3_tier_footage: 🎞️ Footage t_aws_s3_tier_stills: πŸ“· Stills (screenshots) - t_localhost: πŸ§‘πŸ»β€πŸ’» Developer machine - t_localhost_app: πŸƒπŸ»β€βž‘οΈ App - t_localhost_app_target: "`target` (working dir)" - t_localhost_app_target_footage: footage - t_localhost_app_target_pose: pose - t_localhost_postgres: πŸ—„οΈ Postgres - t_localhost_postgres_app: πŸ—„οΈ App local database - t_github: πŸ™ GitHub - t_github_app: πŸƒπŸ»β€βž‘οΈ App + thing_interactions: - edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier: - kind: symmetric + edge_ix__local_dev_process_footage: + kind: sequence things: - - t_aws_az1_subnet_tier_private_lambda_pipe + - t_localhost_app_mediapipe + - t_localhost_app_target_footage + - t_localhost_app_mediapipe + - t_localhost_app_mediapipe + - t_localhost_postgres_app + - t_localhost_app_mediapipe + + edge_ix__aws_process_footage: + kind: sequence + things: + - t_aws_az1_subnet_tier_private_lambda_mediapipe - t_aws_s3_tier_footage - - t_aws_az1_subnet_tier_private_lambda_pipe + - t_aws_az1_subnet_tier_private_lambda_mediapipe + - t_aws_az1_subnet_tier_private_lambda_mediapipe - t_aws_rds_tier + - t_aws_az1_subnet_tier_private_lambda_mediapipe + edge_ix__t_localhost_app__t_localhost_app__tag: kind: cyclic things: @@ -76,12 +95,11 @@ thing_interactions: things: - t_github_app - t_aws_ecr_app - edge_ix__t_aws_s3_tier_footage__t_aws_az1_subnet_tier_private_lambda_pipe: - kind: symmetric - things: - - t_aws_s3_tier_footage - - t_aws_az1_subnet_tier_private_lambda_pipe + thing_layout_edges: + edge_dep__t_localhost_app_web_app__t_localhost_app_target: + from: t_localhost_app_web_app + to: t_localhost_app_target edge_dep__t_localhost__t_aws: from: t_localhost to: t_aws @@ -94,7 +112,16 @@ thing_layout_edges: edge_dep__t_aws_az1_subnet_tier_public__t_aws_az1_subnet_tier_private: from: t_aws_az1_subnet_tier_public to: t_aws_az1_subnet_tier_private + thing_descs: + t_localhost_app_web_app: |- + Web app frontend and API to view\ + and interact with data. + + t_localhost_app_mediapipe: |- + Mediapipe app to process video\ + into pose. + t_aws: |- Limit to one region,\ e.g. ap-southeast-2 or ap-southeast-7\ @@ -104,8 +131,8 @@ thing_descs: Stores docker containers that have been built\ from code. - t_aws_ecr_pipe: |- - Needed because Pipe is too\ + t_aws_ecr_mediapipe: |- + Needed because Mediapipe is too\ large to upload to lambda\ individually. t_aws_ecr_app: Server side API app. @@ -138,7 +165,8 @@ processes: proc_app_local_dev_step_process_pose: Process Pose into Analytics step_thing_interactions: proc_app_local_dev_step_process_video: - - edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier + - edge_ix__local_dev_process_footage + proc_app_publish: name: Publish App app steps: @@ -152,6 +180,7 @@ processes: - edge_ix__t_localhost_app__t_localhost_app__push proc_app_publish_step_git_ecr_publish: - edge_ix__t_localhost_app__t_localhost_app__publish + proc_footage_process: name: Process footage into pose steps: @@ -160,11 +189,37 @@ processes: proc_footage_process_step_trigger_pose_process: Trigger Pose -> Analytics workflow step_thing_interactions: proc_footage_process_step_trigger: - - edge_ix__t_aws_s3_tier_footage__t_aws_az1_subnet_tier_private_lambda_pipe + - edge_ix__aws_process_footage + edge_labels: edge_ix__t_localhost_app__t_localhost_app__tag__0: from: git tag v0.x.x edge_ix__t_localhost_app__t_localhost_app__push__0: from: git push origin v0.x.x + + edge_ix__local_dev_process_footage__0: + from: Fetch footage + edge_ix__local_dev_process_footage__2: + from: "Process footage\\\ninto pose" + edge_ix__local_dev_process_footage__3: + from: Send pose to database + + edge_ix__aws_process_footage__0: + from: Fetch footage + edge_ix__aws_process_footage__2: + from: "Process footage\\\ninto pose" + edge_ix__aws_process_footage__3: + from: Send pose to database + +entity_types: + edge_ix__local_dev_process_footage__1: + - type_interaction_edge_symmetric_reverse_default + edge_ix__local_dev_process_footage__4: + - type_interaction_edge_symmetric_reverse_default + edge_ix__aws_process_footage__1: + - type_interaction_edge_symmetric_reverse_default + edge_ix__aws_process_footage__4: + - type_interaction_edge_symmetric_reverse_default + render_options: interactions_edge_curvature: curved diff --git a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs index 27fc3f2f..bdd773e6 100644 --- a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs +++ b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs @@ -6108,27 +6108,27 @@ fn test_0062_higher_to_lower_rank_curved_edge_builds_without_panic() { .iter() .any(|svg_edge_info| svg_edge_info .edge_id - .starts_with("edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier")), - "Expected edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier edges to be built." + .starts_with("edge_ix__aws_process_footage")), + "Expected edge_ix__aws_process_footage edges to be built." ); } } -/// `edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier__0` enters `t_aws_az1` -/// (described) to reach `t_aws_az1_subnet_tier_private_lambda_pipe`, nested -/// two levels deeper. `t_aws_az1`'s own text-content spacer (marking a column -/// just past its label) sits at its own local position, well inside the wider -/// column that `t_aws_az1`'s cross-container spacers establish further along -/// the path (needed to clear `t_aws_az1_subnet_mgmt` / -/// `t_aws_az1_subnet_tier_public`). +/// `edge_ix__aws_process_footage__0` enters `t_aws_az1` (described) to reach +/// `t_aws_az1_subnet_tier_private_lambda_pipe`, nested two levels deeper. +/// `t_aws_az1`'s own text-content spacer (marking a column just past its label) +/// sits at its own local position, well inside the wider column that +/// `t_aws_az1`'s cross-container spacers establish further along the path +/// (needed to clear `t_aws_az1_subnet_mgmt` / `t_aws_az1_subnet_tier_public`). +/// /// `EdgePathBuilderPass1::spacers_turn_minimize` removes this kind of "notch" /// spacer -- one that sits inside both its neighbours -- by passing it at the /// neighbouring turn's coordinate instead of curving in to it and back out. -/// This was already applied for -/// `EdgeCurvature::Orthogonal`, but not for `EdgeCurvature::Curved` (the -/// curvature `0062` actually uses), so a curved edge's path used to bow out -/// to the wide column, dip back in to `t_aws_az1`'s own label spacer, then -/// jog back out again toward the to-node -- an unnecessary extra Z-bend. +/// This was already applied for `EdgeCurvature::Orthogonal`, but not for +/// `EdgeCurvature::Curved` (the curvature `0062` actually uses), so a curved +/// edge's path used to bow out to the wide column, dip back in to `t_aws_az1`'s +/// own label spacer, then jog back out again toward the to-node -- an +/// unnecessary extra Z-bend. #[test] fn test_0062_curved_edge_does_not_dip_back_to_notch_spacer() { fn path_points(path_d: &str) -> Vec<(f32, f32)> { @@ -6148,10 +6148,9 @@ fn test_0062_curved_edge_does_not_dip_back_to_notch_spacer() { .svg_edge_infos .iter() .find(|svg_edge_info| { - svg_edge_info.edge_id.as_str() - == "edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier__0" + svg_edge_info.edge_id.as_str() == "edge_ix__aws_process_footage__0" }) - .expect("Expected edge_ix__t_aws_s3_tier_footage__t_aws_rds_tier__0"); + .expect("Expected edge_ix__aws_process_footage__0 to exist."); let points = path_points(&edge.path_d); let from_x = points From 0feedafc6cc74c37b14fe4a5f10eac9a0d4e1733 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 20:50:46 +1200 Subject: [PATCH 2/4] Animate edge halo opacity depending on whether edge body is animating. --- .../src/input_to_ir_diagram_mapper.rs | 16 + .../theme_attr_resolver.rs | 123 +++-- crate/input_ir_rt/src/ir_to_taffy_builder.rs | 2 + .../edge_animation_calculator.rs | 199 +++++++- .../edge_model.rs | 68 +++ .../svg_edge_infos_builder.rs | 439 ++++++++++++++---- crate/ir_model/src/ir_diagram.rs | 23 + workspace_tests/src/example_ir.yaml | 2 + .../taffy_to_svg_elements_mapper.rs | 218 +++++++++ 9 files changed, 961 insertions(+), 129 deletions(-) diff --git a/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs b/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs index 2fd1b56c..2d43c218 100644 --- a/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs +++ b/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs @@ -204,6 +204,20 @@ impl InputToIrDiagramMapper { theme_types_styles, ); + // 12b. Resolve the interaction edge halo's (and its outline's) base + // opacity from theme, so the halo's animated active/inactive + // keyframe opacities (built later, per edge) can scale it. + let interaction_edge_halo_opacity = + ThemeAttrResolver::resolve_interaction_edge_halo_opacity( + theme_default, + theme_types_styles, + ); + let interaction_edge_halo_outline_opacity = + ThemeAttrResolver::resolve_interaction_edge_halo_outline_opacity( + theme_default, + theme_types_styles, + ); + // 13. Tailwind classes (and the CSS theme variables they produce) are // focus-dependent, so they are applied separately via // `tailwind_classes_apply`. @@ -299,6 +313,8 @@ impl InputToIrDiagramMapper { render_options: *render_options, css: css.clone(), interaction_edge_halo_stroke_width, + interaction_edge_halo_opacity, + interaction_edge_halo_outline_opacity, }; IrDiagramAndIssues { diagram, issues } diff --git a/crate/input_ir_rt/src/input_to_ir_diagram_mapper/theme_attr_resolver.rs b/crate/input_ir_rt/src/input_to_ir_diagram_mapper/theme_attr_resolver.rs index 9797fc47..ac92b845 100644 --- a/crate/input_ir_rt/src/input_to_ir_diagram_mapper/theme_attr_resolver.rs +++ b/crate/input_ir_rt/src/input_to_ir_diagram_mapper/theme_attr_resolver.rs @@ -25,8 +25,17 @@ use disposition_model_common::{entity::EntityType, Id}; pub(crate) struct ThemeAttrResolver; impl ThemeAttrResolver { - // === Interaction Edge Halo Stroke Width === // - + // === Interaction Edge Halo === // + + /// Default interaction edge halo fill opacity (`0.0..=1.0`), used when + /// `type_interaction_edge_halo` does not configure `ThemeAttr::Opacity`. + /// Matches the `"20"` (percent) set by `InputDiagram::base()`. + const INTERACTION_EDGE_HALO_OPACITY_DEFAULT: f32 = 0.2; + /// Default interaction edge halo outline opacity (`0.0..=1.0`), used + /// when `type_interaction_edge_halo_outline` does not configure + /// `ThemeAttr::Opacity`. Matches the `"40"` (percent) set by + /// `InputDiagram::base()`. + const INTERACTION_EDGE_HALO_OUTLINE_OPACITY_DEFAULT: f32 = 0.4; /// Default interaction edge halo stroke width (pixels), used when /// `type_interaction_edge_halo` does not configure /// `ThemeAttr::StrokeWidth`. @@ -530,46 +539,108 @@ impl ThemeAttrResolver { } } - /// Resolves the interaction edge halo's stroke width from - /// `type_interaction_edge_halo` in `theme_types_styles`. + /// Resolves a single `ThemeAttr`'s raw value from one `EntityType`'s + /// `EdgeDefaults` partials in `theme_types_styles`. /// - /// This is a rendering-only style key (see - /// `EntityType::InteractionEdgeHalo`), so unlike node attributes it has - /// no `NodeId`/`EntityTypes` tiering to walk -- only the one type's + /// This is for rendering-only style keys (see + /// `EntityType::InteractionEdgeHalo`), which -- unlike node attributes -- + /// have no `NodeId`/`EntityTypes` tiering to walk: only the one type's /// `EdgeDefaults` partials (and any style aliases it applies) are - /// checked. Used to size the halo's outline rails proportionally to the - /// halo's own width (see `EdgeHaloOutlineCalculator`), rather than a - /// value hardcoded independently of the theme. - pub(crate) fn resolve_interaction_edge_halo_stroke_width<'id>( + /// checked. Returns `None` when the type has no `EdgeDefaults` partials, + /// or neither the style aliases nor the direct attribute set + /// `theme_attr`. + fn resolve_edge_defaults_attr<'id>( + entity_type: EntityType, + theme_attr: ThemeAttr, theme_default: &ThemeDefault<'id>, theme_types_styles: &ThemeTypesStyles<'id>, - ) -> f32 { - let halo_type_id = EntityTypeId::from(EntityType::InteractionEdgeHalo.into_id()); - let Some(halo_partials) = theme_types_styles - .get(&halo_type_id) - .and_then(|type_styles| type_styles.get(&IdOrDefaults::EdgeDefaults)) - else { - return Self::INTERACTION_EDGE_HALO_STROKE_WIDTH_DEFAULT; - }; + ) -> Option { + let type_id = EntityTypeId::from(entity_type.into_id()); + let partials = theme_types_styles + .get(&type_id) + .and_then(|type_styles| type_styles.get(&IdOrDefaults::EdgeDefaults))?; - let mut stroke_width = None; + let mut resolved = None; // First, check style_aliases_applied (lower priority). - halo_partials + partials .style_aliases_applied() .iter() .filter_map(|alias| theme_default.style_aliases.get(alias)) - .filter_map(|alias_partials| alias_partials.get(&ThemeAttr::StrokeWidth)) + .filter_map(|alias_partials| alias_partials.get(&theme_attr)) .filter_map(|value| value.parse::().ok()) - .for_each(|v| stroke_width = Some(v)); + .for_each(|v| resolved = Some(v)); // Then, check the direct attribute (higher priority). - if let Some(value) = halo_partials.get(&ThemeAttr::StrokeWidth) + if let Some(value) = partials.get(&theme_attr) && let Ok(v) = value.parse::() { - stroke_width = Some(v); + resolved = Some(v); } - stroke_width.unwrap_or(Self::INTERACTION_EDGE_HALO_STROKE_WIDTH_DEFAULT) + resolved + } + + /// Resolves the interaction edge halo's stroke width from + /// `type_interaction_edge_halo` in `theme_types_styles`. + /// + /// Used to size the halo's outline rails proportionally to the halo's + /// own width (see `EdgeHaloOutlineCalculator`), rather than a value + /// hardcoded independently of the theme. + pub(crate) fn resolve_interaction_edge_halo_stroke_width<'id>( + theme_default: &ThemeDefault<'id>, + theme_types_styles: &ThemeTypesStyles<'id>, + ) -> f32 { + Self::resolve_edge_defaults_attr( + EntityType::InteractionEdgeHalo, + ThemeAttr::StrokeWidth, + theme_default, + theme_types_styles, + ) + .unwrap_or(Self::INTERACTION_EDGE_HALO_STROKE_WIDTH_DEFAULT) + } + + /// Resolves the interaction edge halo's fill opacity from + /// `type_interaction_edge_halo` in `theme_types_styles`, normalized from + /// the theme's 0-100 percent scale to a `0.0..=1.0` fraction. + /// + /// Used as the base opacity that the halo's animated active/inactive + /// keyframe opacities scale (see `EdgeAnimationCalculator`), so a themed + /// halo opacity is respected rather than a value hardcoded independently + /// of the theme. + pub(crate) fn resolve_interaction_edge_halo_opacity<'id>( + theme_default: &ThemeDefault<'id>, + theme_types_styles: &ThemeTypesStyles<'id>, + ) -> f32 { + Self::resolve_edge_defaults_attr( + EntityType::InteractionEdgeHalo, + ThemeAttr::Opacity, + theme_default, + theme_types_styles, + ) + .map_or(Self::INTERACTION_EDGE_HALO_OPACITY_DEFAULT, |pct| { + pct / 100.0 + }) + } + + /// Resolves the interaction edge halo outline's opacity from + /// `type_interaction_edge_halo_outline` in `theme_types_styles`, + /// normalized to a `0.0..=1.0` fraction. + /// + /// Used the same way as `resolve_interaction_edge_halo_opacity`, but for + /// the outline rails drawn on top of the halo fill. + pub(crate) fn resolve_interaction_edge_halo_outline_opacity<'id>( + theme_default: &ThemeDefault<'id>, + theme_types_styles: &ThemeTypesStyles<'id>, + ) -> f32 { + Self::resolve_edge_defaults_attr( + EntityType::InteractionEdgeHaloOutline, + ThemeAttr::Opacity, + theme_default, + theme_types_styles, + ) + .map_or(Self::INTERACTION_EDGE_HALO_OUTLINE_OPACITY_DEFAULT, |pct| { + pct / 100.0 + }) } } diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder.rs b/crate/input_ir_rt/src/ir_to_taffy_builder.rs index 473cc6a0..1727bec4 100644 --- a/crate/input_ir_rt/src/ir_to_taffy_builder.rs +++ b/crate/input_ir_rt/src/ir_to_taffy_builder.rs @@ -143,6 +143,8 @@ impl IrToTaffyBuilder<'_> { render_options, css: _, interaction_edge_halo_stroke_width, + interaction_edge_halo_opacity: _, + interaction_edge_halo_outline_opacity: _, } = ir_diagram; let DimensionAndLod { dimension, lod } = dimension_and_lod; diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs index 4dd4c688..27b9cfb9 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs @@ -1,6 +1,25 @@ use std::fmt::Write; -use super::edge_model::{EdgeAnimation, EdgeAnimationParams, EdgePathInfo, EdgeType}; +use super::edge_model::{ + EdgeAnimation, EdgeAnimationParams, EdgePathInfo, EdgeType, HaloAnimationParams, +}; + +/// Multiplier applied to the halo's (or its outline's) themed base opacity +/// while it -- or a paired request/response neighbour -- is travelling. +const HALO_OPACITY_ACTIVE_MULTIPLIER: f64 = 1.0; +/// Multiplier applied to the halo's (or its outline's) themed base opacity +/// outside any active window. +const HALO_OPACITY_INACTIVE_MULTIPLIER: f64 = 0.4; +/// Percentage width of the halo opacity transition zone immediately before +/// each active-window boundary. +/// +/// The *previous* opacity is held until this far before the boundary, then +/// the *new* opacity takes over exactly at the boundary -- a deliberately +/// tiny window so the change reads as a sudden snap rather than a gradual +/// fade, without relying on two keyframe entries at the exact same +/// percentage (which some renderers interpolate across the entire +/// surrounding segment instead of treating as instantaneous). +const HALO_OPACITY_TRANSITION_PCT: f64 = 0.2; /// Calculates stroke-dasharray animations and CSS keyframes for interaction /// edges. @@ -25,12 +44,22 @@ impl EdgeAnimationCalculator { /// * `edge_group_animation_duration_total_s`: Duration of the animation for /// the edges for the entire edge group, which includes the pause at the /// end of the animation. + /// * `halo_animation_params`: Parameters needed to compute this edge's halo + /// (and halo outline) opacity animation -- see `HaloAnimationParams`. pub(super) fn calculate( edge_animation_params: EdgeAnimationParams, edge_path_info: &EdgePathInfo<'_, '_>, edge_group_cycle_distance: f64, edge_group_animation_duration_total_s: f64, + halo_animation_params: HaloAnimationParams, ) -> EdgeAnimation { + let HaloAnimationParams { + is_reverse: halo_is_reverse, + prev_window: halo_prev_window, + next_window: halo_next_window, + opacity_base: halo_opacity_base, + outline_opacity_base: halo_outline_opacity_base, + } = halo_animation_params; let EdgePathInfo { edge_id, edge: _, @@ -75,14 +104,15 @@ impl EdgeAnimationCalculator { // `visible_segments_length` -- is what keeps every edge in the group // moving at the same pixel speed: the window width (in cycle-distance // units) equals the distance the dash actually travels. - let travel = visible_segments_length + trailing_gap; - // Keyframe percentages for this edge's slot within the cycle. The edge // animates from `preceding_travel` to `preceding_travel + travel`, so it // starts exactly when the previous edge finished, and the leftover up to // 100% is the constant end-of-cycle pause. - let start_pct = preceding_travel / edge_group_cycle_distance * 100.0; - let end_pct = (preceding_travel + travel) / edge_group_cycle_distance * 100.0; + let (start_pct, end_pct) = Self::active_window_pct( + edge_animation_params, + edge_path_info, + edge_group_cycle_distance, + ); // The arrow head tracks the comet's leading tip, which travels the full // path length from the `from` node to the `to` node. The tip reaches the @@ -178,6 +208,46 @@ impl EdgeAnimationCalculator { arrow_head_keyframe_css.push('}'); arrow_head_keyframe_css.push('\n'); + // Build the halo opacity @keyframes rule. + // + // The halo is fully opaque while this edge's own comet travels + // (`[start_pct, end_pct]`), and also while a paired request/response + // neighbour's comet travels: the next edge's window when this edge is + // the request (or unpaired) direction and the next edge is the + // response, or the previous edge's window when this edge is the + // response direction and the previous edge is the request (or + // unpaired) direction. It is dimmed everywhere else. + let paired_window = if halo_is_reverse { + halo_prev_window.filter(|window| !window.is_reverse) + } else { + halo_next_window.filter(|window| window.is_reverse) + }; + let mut halo_active_windows = vec![(start_pct, end_pct)]; + if let Some(paired_window) = paired_window { + halo_active_windows.push((paired_window.start_pct, paired_window.end_pct)); + } + halo_active_windows + .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let halo_active_windows = Self::halo_windows_merge(halo_active_windows); + + let halo_animation_name = format!("{edge_id_with_hyphens}--halo-opacity"); + let halo_keyframe_css = Self::halo_keyframe_css_build( + &halo_animation_name, + &halo_active_windows, + halo_opacity_base, + ); + + // The outline shares the exact same active windows (and therefore + // timing) as the halo fill -- only its themed base opacity differs + // -- so it gets its own `@keyframes` rule rather than reusing the + // halo's, since the two animate different numeric opacity values. + let halo_outline_animation_name = format!("{edge_id_with_hyphens}--halo-outline-opacity"); + let halo_outline_keyframe_css = Self::halo_keyframe_css_build( + &halo_outline_animation_name, + &halo_active_windows, + halo_outline_opacity_base, + ); + EdgeAnimation { dasharray, keyframe_css, @@ -185,7 +255,126 @@ impl EdgeAnimationCalculator { edge_animation_duration_s: edge_group_animation_duration_total_s, arrow_head_keyframe_css, arrow_head_animation_name, + halo_keyframe_css, + halo_animation_name, + halo_outline_keyframe_css, + halo_outline_animation_name, + } + } + + /// Computes the percentage bounds -- within the edge group's shared + /// animation cycle -- of the window during which this edge's comet is + /// travelling. + /// + /// Shared by `Self::calculate` (for this edge's own dashoffset keyframes) + /// and by callers that need to know an edge's window ahead of calling + /// `Self::calculate` for a neighbouring edge (see `EdgeHaloWindow`). + pub(super) fn active_window_pct( + edge_animation_params: EdgeAnimationParams, + edge_path_info: &EdgePathInfo<'_, '_>, + edge_group_cycle_distance: f64, + ) -> (f64, f64) { + let visible_segments_length = edge_animation_params.visible_segments_length; + let trailing_gap = edge_path_info.path_length.max(visible_segments_length); + let travel = visible_segments_length + trailing_gap; + + let start_pct = edge_path_info.preceding_travel / edge_group_cycle_distance * 100.0; + let end_pct = + (edge_path_info.preceding_travel + travel) / edge_group_cycle_distance * 100.0; + (start_pct, end_pct) + } + + /// Merges touching or overlapping active windows (sorted ascending by + /// start) into disjoint intervals, so keyframes built from them don't dip + /// back to the inactive opacity for a zero-width gap. + fn halo_windows_merge(windows: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(windows.len()); + for window in windows { + if let Some(last) = merged.last_mut() + && window.0 <= last.1 + { + last.1 = last.1.max(window.1); + continue; + } + merged.push(window); + } + merged + } + + /// Builds a halo (or halo outline) `@keyframes` rule from its disjoint + /// active windows and themed base opacity. + /// + /// The rule sits at `base_opacity * HALO_OPACITY_ACTIVE_MULTIPLIER` + /// during each active window and `base_opacity * + /// HALO_OPACITY_INACTIVE_MULTIPLIER` elsewhere, so a themed opacity is + /// scaled rather than replaced by a value hardcoded independently of the + /// theme. Each boundary transitions within + /// `HALO_OPACITY_TRANSITION_PCT` immediately before it, reading as a + /// sudden snap rather than a gradual fade. + fn halo_keyframe_css_build( + halo_animation_name: &str, + active_windows: &[(f64, f64)], + base_opacity: f64, + ) -> String { + let active_opacity = base_opacity * HALO_OPACITY_ACTIVE_MULTIPLIER; + let inactive_opacity = base_opacity * HALO_OPACITY_INACTIVE_MULTIPLIER; + + let mut keyframe_css = format!("@keyframes {halo_animation_name} {{ "); + + let starts_inactive = match active_windows.first() { + Some(&(start_pct, _)) => start_pct > 0.0, + None => true, + }; + if starts_inactive { + let _ = write!(keyframe_css, "0% {{ opacity: {inactive_opacity:.3}; }} "); + } + + for &(start_pct, end_pct) in active_windows { + if start_pct > 0.0 { + // Hold the inactive opacity until just before this window + // starts, then snap to active exactly at `start_pct`. + let rise_pct = (start_pct - HALO_OPACITY_TRANSITION_PCT).max(0.0); + let _ = write!( + keyframe_css, + "{rise_pct:.1}% {{ opacity: {inactive_opacity:.3}; }} " + ); + } + let _ = write!( + keyframe_css, + "{start_pct:.1}% {{ opacity: {active_opacity:.3}; }} " + ); + + if end_pct < 100.0 { + // Hold the active opacity until just before this window + // ends, then snap to inactive exactly at `end_pct`. + let fall_pct = (end_pct - HALO_OPACITY_TRANSITION_PCT).max(start_pct); + let _ = write!( + keyframe_css, + "{fall_pct:.1}% {{ opacity: {active_opacity:.3}; }} " + ); + let _ = write!( + keyframe_css, + "{end_pct:.1}% {{ opacity: {inactive_opacity:.3}; }} " + ); + } else { + let _ = write!( + keyframe_css, + "{end_pct:.1}% {{ opacity: {active_opacity:.3}; }} " + ); + } } + + let ends_inactive = match active_windows.last() { + Some(&(_, end_pct)) => end_pct < 100.0, + None => true, + }; + if ends_inactive { + let _ = write!(keyframe_css, "100% {{ opacity: {inactive_opacity:.3}; }} "); + } + + keyframe_css.push('}'); + keyframe_css.push('\n'); + keyframe_css } /// Generates the visible segment lengths using a geometric series. diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs index 8b181171..7a2609d1 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs @@ -130,6 +130,66 @@ pub(super) enum EdgeType { PairResponse, } +/// The active keyframe window and pairing direction of one edge within its +/// edge group's shared animation cycle. +/// +/// Precomputed for every edge in a group before the per-edge animation pass +/// so that an edge's halo animation can reference the timing of its +/// immediate neighbour (see `HaloAnimationParams::prev_window` / +/// `next_window`). +/// +/// # Examples +/// +/// `EdgeHaloWindow { is_reverse: false, start_pct: 0.0, end_pct: 18.2 }` +#[derive(Clone, Copy, Debug)] +pub(super) struct EdgeHaloWindow { + /// Whether this edge's raw `EntityType`s include + /// `InteractionEdgeSymmetricReverseDefault`. This is deliberately not + /// the same as `EdgePathInfo::edge_type == EdgeType::PairResponse` -- + /// see `HaloAnimationParams::is_reverse`. + pub(super) is_reverse: bool, + /// Percentage into the edge group's animation cycle at which this + /// edge's comet starts travelling. + pub(super) start_pct: f64, + /// Percentage into the edge group's animation cycle at which this + /// edge's comet finishes travelling. + pub(super) end_pct: f64, +} + +/// Parameters needed to compute an interaction edge's halo opacity animation +/// (and its outline's), kept separate from the edge's own stroke-dashoffset / +/// arrowhead animation parameters. +#[derive(Clone, Copy, Debug)] +pub(super) struct HaloAnimationParams { + /// Whether this edge's raw `EntityType`s include + /// `InteractionEdgeSymmetricReverseDefault`. + /// + /// This drives halo pairing only, and is deliberately not the same as + /// `EdgePathInfo::edge_type == EdgeType::PairResponse` (which drives the + /// dasharray/comet direction): a `sequence`-kind edge manually tagged + /// `InteractionEdgeSymmetricReverseDefault` also keeps its + /// `InteractionEdgeSequenceForwardDefault` default, and + /// `edge_type_determine` -- correctly, for path-direction purposes -- + /// classifies that combination as `EdgeType::Unpaired`. + pub(super) is_reverse: bool, + /// Active keyframe window of the edge immediately preceding this one in + /// the edge group, if any. When `is_reverse` and the previous edge is + /// not reverse, the previous edge's window is also treated as an active + /// window for this edge's halo. + pub(super) prev_window: Option, + /// Active keyframe window of the edge immediately following this one in + /// the edge group, if any. When this edge is not `is_reverse` and the + /// next edge is reverse, the next edge's window is also treated as an + /// active window for this edge's halo. + pub(super) next_window: Option, + /// Themed base opacity (`0.0..=1.0`) of the halo fill, from + /// `IrDiagram::interaction_edge_halo_opacity`. + pub(super) opacity_base: f64, + /// Themed base opacity (`0.0..=1.0`) of the halo outline, from + /// `IrDiagram::interaction_edge_halo_outline_opacity`. + pub(super) outline_opacity_base: f64, +} + /// Information needed to compute `SvgEdgeInfo`s including their animation. /// /// This is collated because the sum of all path lengths in an edge group are @@ -206,4 +266,12 @@ pub(super) struct EdgeAnimation { pub(super) arrow_head_keyframe_css: String, /// Unique animation name for the arrowhead keyframes rule. pub(super) arrow_head_animation_name: String, + /// The CSS `@keyframes` rule for the halo opacity animation. + pub(super) halo_keyframe_css: String, + /// Unique animation name for the halo opacity keyframes rule. + pub(super) halo_animation_name: String, + /// The CSS `@keyframes` rule for the halo outline's opacity animation. + pub(super) halo_outline_keyframe_css: String, + /// Unique animation name for the halo outline opacity keyframes rule. + pub(super) halo_outline_animation_name: String, } diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs index 4ca75f9d..44a7e0b6 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs @@ -28,8 +28,9 @@ use crate::{ edge_face_contact_tracker::{EdgeFaceContactTracker, CONTACT_GAP_MIN_PX}, edge_halo_outline_calculator::{EdgeHaloOutlineCalculator, EdgeHaloOutlineRails}, edge_model::{ - EdgeAnimationParams, EdgeContactPointOffsets, EdgePathInfo, EdgeType, NodeIdAndFace, - NodeIdAndFaceToContactPointOffsets, PathBounds, PathMidpoint, + EdgeAnimationParams, EdgeContactPointOffsets, EdgeHaloWindow, EdgePathInfo, EdgeType, + HaloAnimationParams, NodeIdAndFace, NodeIdAndFaceToContactPointOffsets, PathBounds, + PathMidpoint, }, edge_path_builder_pass_1::{EdgeFaceOffset, SpacerCoordinates}, ortho_protrusion_calculator::{OrthoProtrusionCalculator, OrthoProtrusionOutcome}, @@ -37,7 +38,8 @@ use crate::{ EdgePathLocusCalculator, SpacerCoordinatesResolver, StringCharReplacer, SvgNodeInfoByNodeId, }, - AbsoluteCoordinates, EdgeFaceAssigner, EdgeIdGenerator, TaffyNodeAbsoluteCoordinatesCalculator, + AbsoluteCoordinates, EdgeFaceAssigner, EdgeHaloIdGenerator, EdgeHaloOutlineIdGenerator, + EdgeIdGenerator, TaffyNodeAbsoluteCoordinatesCalculator, }; /// Builds [`SvgEdgeInfo`]s for all edges in the diagram from edge groups and @@ -94,9 +96,14 @@ impl SvgEdgeInfosBuilder { edge_face_assignments, process_step_entities, render_options, + interaction_edge_halo_opacity, + interaction_edge_halo_outline_opacity, .. } = ir_diagram; let rank_dir = render_options.rank_dir; + let interaction_edge_halo_opacity_base = f64::from(*interaction_edge_halo_opacity); + let interaction_edge_halo_outline_opacity_base = + f64::from(*interaction_edge_halo_outline_opacity); // Build a reverse map: entity (edge group) ID -> list of process step NodeIds. // This allows efficient lookup of which process steps reference a given edge @@ -330,107 +337,164 @@ impl SvgEdgeInfosBuilder { .map(Vec::as_slice) .unwrap_or(&[]); - edge_path_infos.into_iter().for_each(|edge_path_info| { - // Compute animation for interaction edges. - let is_interaction_edge = entity_types - .get(AsRef::>::as_ref(&edge_path_info.edge_id)) - .map(|edge_entity_types| { - edge_entity_types - .iter() - .any(EntityType::is_interaction_edge) - }) - .unwrap_or(false); - - if is_interaction_edge { - let css_animation_append_params = CssAnimationAppendParams { - tailwind_classes, - css, + // Precomputed per-edge keyframe windows, so a Forward edge's halo + // animation can reference the next edge's window, and a Reverse + // edge's halo animation can reference the previous edge's + // window, without re-deriving them mid-iteration. + // + // Halo pairing uses the edge's raw `EntityType` (whether + // `InteractionEdgeSymmetricReverseDefault` was assigned to it), + // not `EdgePathInfo::edge_type` / `EdgeType::PairResponse`: a + // `sequence`-kind edge that is manually tagged + // `InteractionEdgeSymmetricReverseDefault` (e.g. to mark a + // request/response round trip within an otherwise-forward + // sequence) also keeps its + // `InteractionEdgeSequenceForwardDefault` default, and + // `edge_type_determine` -- correctly, for dasharray/path + // direction purposes -- classifies that combination as + // `EdgeType::Unpaired` rather than `PairResponse`. This mirrors + // the same raw-`EntityType` check `tailwind_classes_builder.rs` + // already uses to pick the halo's static forward/reverse colour. + let edge_halo_windows: Vec = edge_path_infos + .iter() + .map(|edge_path_info| { + let (start_pct, end_pct) = EdgeAnimationCalculator::active_window_pct( edge_animation_params, + edge_path_info, edge_group_cycle_distance, - edge_group_animation_duration_total_s, - edge_path_info: &edge_path_info, - edge_animation_active, - focus_mode, - associated_process_steps, - }; - Self::css_animation_append(css_animation_append_params); - } + ); + let is_reverse = entity_types + .get(AsRef::>::as_ref(&edge_path_info.edge_id)) + .is_some_and(|edge_entity_types| { + edge_entity_types + .contains(&EntityType::InteractionEdgeSymmetricReverseDefault) + }); + EdgeHaloWindow { + is_reverse, + start_pct, + end_pct, + } + }) + .collect(); - let EdgePathInfo { - edge_id, - edge, - edge_type: _, - path, - path_length: _, - preceding_travel: _, - ortho_protrusion_params, - } = edge_path_info; - - let path_d = path.to_svg(); - - // Compute arrowhead path. - let (arrow_head_path, locus_path) = if is_interaction_edge { - // Origin-centred V-shape; CSS offset-path handles - // positioning and rotation. - let arrow_head_path = ArrowHeadBuilder::build_origin_arrow_head(); - // Positioned V-shape at the `to` node end of the edge. - let arrow_head_path_at_to_node = - ArrowHeadBuilder::build_static_arrow_head(&path); - let locus_path = - EdgePathLocusCalculator::calculate(&path, &arrow_head_path_at_to_node); - - (arrow_head_path, locus_path) - } else { - // Positioned V-shape at the `to` node end of the edge. - let arrow_head_path = ArrowHeadBuilder::build_static_arrow_head(&path); - let locus_path = EdgePathLocusCalculator::calculate(&path, &arrow_head_path); + edge_path_infos + .into_iter() + .enumerate() + .for_each(|(edge_index, edge_path_info)| { + // Compute animation for interaction edges. + let is_interaction_edge = entity_types + .get(AsRef::>::as_ref(&edge_path_info.edge_id)) + .map(|edge_entity_types| { + edge_entity_types + .iter() + .any(EntityType::is_interaction_edge) + }) + .unwrap_or(false); + + if is_interaction_edge { + let halo_animation_params = HaloAnimationParams { + is_reverse: edge_halo_windows[edge_index].is_reverse, + prev_window: (edge_index > 0) + .then(|| edge_halo_windows[edge_index - 1]), + next_window: edge_halo_windows.get(edge_index + 1).copied(), + opacity_base: interaction_edge_halo_opacity_base, + outline_opacity_base: interaction_edge_halo_outline_opacity_base, + }; + let css_animation_append_params = CssAnimationAppendParams { + tailwind_classes, + css, + edge_animation_params, + edge_group_cycle_distance, + edge_group_animation_duration_total_s, + edge_path_info: &edge_path_info, + edge_animation_active, + focus_mode, + associated_process_steps, + halo_animation_params, + interaction_edge_halo_enabled: render_options + .interaction_edge_halo + .is_enabled(), + }; + Self::css_animation_append(css_animation_append_params); + } - (arrow_head_path, locus_path) - }; - let arrow_head_path_d = arrow_head_path.to_svg(); - let locus_path_d = locus_path.to_svg(); - - let (halo_outline_rail_a_path_d, halo_outline_rail_b_path_d) = - if is_interaction_edge && render_options.interaction_edge_halo.is_enabled() { - let EdgeHaloOutlineRails { rail_a, rail_b } = - EdgeHaloOutlineCalculator::calculate( - &path, - f64::from(ir_diagram.interaction_edge_halo_stroke_width), - ); - (rail_a.to_svg(), rail_b.to_svg()) + let EdgePathInfo { + edge_id, + edge, + edge_type: _, + path, + path_length: _, + preceding_travel: _, + ortho_protrusion_params, + } = edge_path_info; + + let path_d = path.to_svg(); + + // Compute arrowhead path. + let (arrow_head_path, locus_path) = if is_interaction_edge { + // Origin-centred V-shape; CSS offset-path handles + // positioning and rotation. + let arrow_head_path = ArrowHeadBuilder::build_origin_arrow_head(); + // Positioned V-shape at the `to` node end of the edge. + let arrow_head_path_at_to_node = + ArrowHeadBuilder::build_static_arrow_head(&path); + let locus_path = + EdgePathLocusCalculator::calculate(&path, &arrow_head_path_at_to_node); + + (arrow_head_path, locus_path) } else { - (String::new(), String::new()) - }; + // Positioned V-shape at the `to` node end of the edge. + let arrow_head_path = ArrowHeadBuilder::build_static_arrow_head(&path); + let locus_path = + EdgePathLocusCalculator::calculate(&path, &arrow_head_path); - let tooltip = ir_diagram - .entity_tooltips - .get(edge_id.as_ref()) - .cloned() - .unwrap_or_default(); + (arrow_head_path, locus_path) + }; + let arrow_head_path_d = arrow_head_path.to_svg(); + let locus_path_d = locus_path.to_svg(); + + let (halo_outline_rail_a_path_d, halo_outline_rail_b_path_d) = + if is_interaction_edge && render_options.interaction_edge_halo.is_enabled() + { + let EdgeHaloOutlineRails { rail_a, rail_b } = + EdgeHaloOutlineCalculator::calculate( + &path, + f64::from(ir_diagram.interaction_edge_halo_stroke_width), + ); + (rail_a.to_svg(), rail_b.to_svg()) + } else { + (String::new(), String::new()) + }; - // Route-reversed edges are stored mirrored, so swap the - // endpoints back to the user-declared orientation for - // consumers (focus / hover / diagnostics). - let (node_id_from, node_id_to) = if edge_route_reversals.contains(&edge_id) { - (edge.to.clone(), edge.from.clone()) - } else { - (edge.from.clone(), edge.to.clone()) - }; + let tooltip = ir_diagram + .entity_tooltips + .get(edge_id.as_ref()) + .cloned() + .unwrap_or_default(); + + // Route-reversed edges are stored mirrored, so swap the + // endpoints back to the user-declared orientation for + // consumers (focus / hover / diagnostics). + let (node_id_from, node_id_to) = if edge_route_reversals.contains(&edge_id) { + (edge.to.clone(), edge.from.clone()) + } else { + (edge.from.clone(), edge.to.clone()) + }; - svg_edge_infos.push(SvgEdgeInfo::new( - edge_id, - edge_group_id.clone(), - node_id_from, - node_id_to, - path_d, - arrow_head_path_d, - locus_path_d, - halo_outline_rail_a_path_d, - halo_outline_rail_b_path_d, - tooltip, - ortho_protrusion_params, - )); - }); + svg_edge_infos.push(SvgEdgeInfo::new( + edge_id, + edge_group_id.clone(), + node_id_from, + node_id_to, + path_d, + arrow_head_path_d, + locus_path_d, + halo_outline_rail_a_path_d, + halo_outline_rail_b_path_d, + tooltip, + ortho_protrusion_params, + )); + }); } SvgEdgeInfosBuilt { @@ -2443,12 +2507,15 @@ impl SvgEdgeInfosBuilder { edge_animation_active, focus_mode, associated_process_steps, + halo_animation_params, + interaction_edge_halo_enabled, } = css_animation_append_params; let edge_animation = EdgeAnimationCalculator::calculate( edge_animation_params, edge_path_info, edge_group_cycle_distance, edge_group_animation_duration_total_s, + halo_animation_params, ); // Append dasharray and animate tailwind classes to this @@ -2523,16 +2590,45 @@ impl SvgEdgeInfosBuilder { focus_mode, associated_process_steps, &edge_animation.arrow_head_animation_name, - animation_duration, + animation_duration.clone(), forward_path_svg, ); - // Append CSS keyframes for both edge stroke and arrowhead. + // Append CSS keyframes for edge stroke and arrowhead. if !css.is_empty() { css.push('\n'); } css.push_str(&edge_animation.keyframe_css); css.push_str(&edge_animation.arrow_head_keyframe_css); + + // The halo's (and its outline's) tailwind-classes entity keys only + // exist when halo rendering is enabled -- attaching an animate class + // (or pushing keyframes) when disabled would spuriously create those + // keys and make `svg_elements_to_svg_mapper.rs` render halo/outline + // paths that should not exist. + if interaction_edge_halo_enabled { + Self::css_animation_append_halo_classes( + tailwind_classes, + edge_path_info, + edge_animation_active, + focus_mode, + associated_process_steps, + &edge_animation.halo_animation_name, + &animation_duration, + ); + css.push_str(&edge_animation.halo_keyframe_css); + + Self::css_animation_append_halo_outline_classes( + tailwind_classes, + edge_path_info, + edge_animation_active, + focus_mode, + associated_process_steps, + &edge_animation.halo_outline_animation_name, + &animation_duration, + ); + css.push_str(&edge_animation.halo_outline_keyframe_css); + } } /// Appends CSS classes for the arrowhead animation to the diagram's @@ -2590,6 +2686,143 @@ impl SvgEdgeInfosBuilder { .into_static(); tailwind_classes.insert(arrow_head_entity_id, arrow_head_classes); } + + /// Appends CSS classes for the halo opacity animation to the diagram's + /// tailwind classes. + /// + /// Unlike the arrowhead's synthetic entity (which starts with no prior + /// classes), `{edge_id}__halo` already holds static forward/reverse + /// classes written earlier by `tailwind_classes_builder.rs`'s + /// `interaction_edge_halo_classes_build` (colour, stroke width, base + /// opacity), so the animate class is appended to the existing classes + /// rather than overwriting them -- mirroring how the edge body's + /// existing classes are appended to in `Self::css_animation_append`. + #[allow(clippy::too_many_arguments)] + fn css_animation_append_halo_classes<'id>( + tailwind_classes: &mut EntityTailwindClasses<'id>, + edge_path_info: &EdgePathInfo<'_, 'id>, + edge_animation_active: EdgeAnimationActive, + focus_mode: TailwindFocusMode<'_, 'id>, + associated_process_steps: &[&NodeId<'id>], + halo_animation_name: &str, + animation_duration: &str, + ) { + let halo_classes = Self::animate_classes_build( + halo_animation_name, + animation_duration, + edge_animation_active, + focus_mode, + associated_process_steps, + ); + if halo_classes.is_empty() { + return; + } + + let halo_id = EdgeHaloIdGenerator::generate(&edge_path_info.edge_id); + Self::tailwind_classes_append(tailwind_classes, halo_id, halo_classes); + } + + /// Appends CSS classes for the halo outline's opacity animation to the + /// diagram's tailwind classes. + /// + /// Mirrors `Self::css_animation_append_halo_classes`, but targets the + /// `{edge_id}__halo_outline` entity (already holding static + /// forward/reverse classes written by `tailwind_classes_builder.rs`) + /// rather than `{edge_id}__halo`. + #[allow(clippy::too_many_arguments)] + fn css_animation_append_halo_outline_classes<'id>( + tailwind_classes: &mut EntityTailwindClasses<'id>, + edge_path_info: &EdgePathInfo<'_, 'id>, + edge_animation_active: EdgeAnimationActive, + focus_mode: TailwindFocusMode<'_, 'id>, + associated_process_steps: &[&NodeId<'id>], + halo_outline_animation_name: &str, + animation_duration: &str, + ) { + let halo_outline_classes = Self::animate_classes_build( + halo_outline_animation_name, + animation_duration, + edge_animation_active, + focus_mode, + associated_process_steps, + ); + if halo_outline_classes.is_empty() { + return; + } + + let halo_outline_id = EdgeHaloOutlineIdGenerator::generate(&edge_path_info.edge_id); + Self::tailwind_classes_append(tailwind_classes, halo_outline_id, halo_outline_classes); + } + + /// Builds the `animate-[{animation_name}_{duration}s_linear_infinite]` + /// tailwind class (with the `EdgeAnimationActive::OnProcessStepFocus` + /// focus-mode branching applied), for an entity that needs no other + /// classes alongside it (unlike the arrowhead, which also carries + /// `offset-path` / `stroke-dasharray` classes). + /// + /// Returns an empty string when nothing should animate (`Baked` focus + /// mode with no associated, currently-focused process step). + fn animate_classes_build<'id>( + animation_name: &str, + animation_duration: &str, + edge_animation_active: EdgeAnimationActive, + focus_mode: TailwindFocusMode<'_, 'id>, + associated_process_steps: &[&NodeId<'id>], + ) -> String { + let mut classes = String::new(); + match edge_animation_active { + EdgeAnimationActive::Always => classes.push_str(&format!( + "animate-[{animation_name}_{animation_duration}s_linear_infinite]" + )), + EdgeAnimationActive::OnProcessStepFocus => match focus_mode { + TailwindFocusMode::Interactive => { + associated_process_steps + .iter() + .for_each(|process_step_id| { + classes.push_str(&format!( + "\ngroup-has-[#{process_step_id}:focus-within]:\ + animate-[{animation_name}_{animation_duration}s_linear_infinite]" + )); + }); + } + TailwindFocusMode::Baked { .. } => { + // In baked mode, the focused step's interacting edges + // animate unconditionally; all other edges do not + // animate. + if Self::focus_baked_step_associated(focus_mode, associated_process_steps) { + classes.push_str(&format!( + "animate-[{animation_name}_{animation_duration}s_linear_infinite]" + )); + } + } + }, + } + classes + } + + /// Appends `new_classes` onto an entity's existing tailwind classes + /// (rather than overwriting them), inserting a blank-separated join when + /// the entity already has classes. + /// + /// Used for entities (like `{edge_id}__halo` / `{edge_id}__halo_outline`) + /// that already hold static classes written earlier in the pipeline by + /// `tailwind_classes_builder.rs`. + fn tailwind_classes_append<'id>( + tailwind_classes: &mut EntityTailwindClasses<'id>, + entity_id: Id<'static>, + new_classes: String, + ) { + let existing = tailwind_classes + .get(&entity_id) + .cloned() + .unwrap_or_default(); + let combined = if existing.is_empty() { + new_classes + } else { + format!("{existing}\n{new_classes}") + }; + tailwind_classes.insert(entity_id, combined); + } } // === Supporting types === // @@ -2870,6 +3103,16 @@ struct CssAnimationAppendParams<'f, 'edge, 'id> { edge_animation_active: EdgeAnimationActive, focus_mode: TailwindFocusMode<'f, 'id>, associated_process_steps: &'f [&'f NodeId<'id>], + /// Parameters for this edge's halo (and halo outline) opacity animation. + halo_animation_params: HaloAnimationParams, + /// Whether `RenderOptions::interaction_edge_halo` is enabled. + /// + /// The halo's tailwind-classes entity key (`{edge_id}__halo`) only + /// exists in `tailwind_classes` when this is `true` -- its mere presence + /// is what `svg_elements_to_svg_mapper.rs` uses to decide whether to + /// render the halo `` at all, so the halo animation classes and + /// keyframes must not be attached when this is `false`. + interaction_edge_halo_enabled: bool, } #[cfg(test)] diff --git a/crate/ir_model/src/ir_diagram.rs b/crate/ir_model/src/ir_diagram.rs index 47ffedd1..c64fd157 100644 --- a/crate/ir_model/src/ir_diagram.rs +++ b/crate/ir_model/src/ir_diagram.rs @@ -281,6 +281,27 @@ pub struct IrDiagram<'id> { /// directly rather than through `InputToIrDiagramMapper`). #[serde(default)] pub interaction_edge_halo_stroke_width: f32, + + /// Resolved halo fill opacity, as a `0.0..=1.0` fraction (not the 0-100 + /// percent scale used in the theme string), from `ThemeAttr::Opacity` on + /// `type_interaction_edge_halo`. + /// + /// Used as the base opacity that the halo's animated active/inactive + /// keyframe opacities (see `EdgeAnimationCalculator`) scale, so a themed + /// halo opacity is respected rather than a value hardcoded independently + /// of the theme. Defaults to `0.0` when not resolved (e.g. an + /// `IrDiagram` built directly rather than through + /// `InputToIrDiagramMapper`). + #[serde(default)] + pub interaction_edge_halo_opacity: f32, + + /// Resolved halo outline opacity, as a `0.0..=1.0` fraction, from + /// `ThemeAttr::Opacity` on `type_interaction_edge_halo_outline`. + /// + /// Used the same way as `interaction_edge_halo_opacity`, but for the + /// outline rails drawn on top of the halo fill. + #[serde(default)] + pub interaction_edge_halo_outline_opacity: f32, } impl<'id> IrDiagram<'id> { @@ -321,6 +342,8 @@ impl<'id> IrDiagram<'id> { render_options: self.render_options, css: self.css, interaction_edge_halo_stroke_width: self.interaction_edge_halo_stroke_width, + interaction_edge_halo_opacity: self.interaction_edge_halo_opacity, + interaction_edge_halo_outline_opacity: self.interaction_edge_halo_outline_opacity, } } } diff --git a/workspace_tests/src/example_ir.yaml b/workspace_tests/src/example_ir.yaml index ccd931e2..e2501780 100644 --- a/workspace_tests/src/example_ir.yaml +++ b/workspace_tests/src/example_ir.yaml @@ -3684,3 +3684,5 @@ css: |- 100% { stroke-dasharray: 3; stroke-dashoffset: 0; } } interaction_edge_halo_stroke_width: 8.0 +interaction_edge_halo_opacity: 0.2 +interaction_edge_halo_outline_opacity: 0.4 diff --git a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs index bdd773e6..e9ff2f96 100644 --- a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs +++ b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs @@ -613,6 +613,114 @@ fn test_svg_edge_infos_interaction_arrow_head_css_keyframes() -> Result<(), Taff Ok(()) } +#[test] +fn test_svg_edge_infos_interaction_halo_opacity_css_keyframes() -> Result<(), TaffyError> { + // `example_ir.yaml` sets `interaction_edge_halo_opacity: 0.2` and + // `interaction_edge_halo_outline_opacity: 0.4` (matching + // `InputDiagram::base()`'s `type_interaction_edge_halo` / + // `type_interaction_edge_halo_outline` theme opacities). The halo (and + // its outline) animate between `base_opacity * + // HALO_OPACITY_ACTIVE_MULTIPLIER` (1.0, i.e. unchanged) and + // `base_opacity * HALO_OPACITY_INACTIVE_MULTIPLIER` (0.4). + const HALO_OPACITY_ACTIVE: &str = "0.200"; + const HALO_OPACITY_INACTIVE: &str = "0.080"; + const HALO_OUTLINE_OPACITY_ACTIVE: &str = "0.400"; + const HALO_OUTLINE_OPACITY_INACTIVE: &str = "0.160"; + + for svg_elements in build_svg_elements_from_example_ir() { + let ix_edges: Vec<_> = svg_elements + .svg_edge_infos + .iter() + .filter(|e| e.edge_id.as_str().starts_with("edge_ix_")) + .collect(); + + let keyframes_block = |animation_name: &str| -> &str { + let keyframes_prefix = format!("@keyframes {animation_name}"); + let css = &svg_elements.css; + let start_idx = css.find(&keyframes_prefix).unwrap_or_else(|| { + panic!( + "CSS should contain '{keyframes_prefix}', got: {}", + css.as_str() + ) + }); + let block = &css[start_idx..]; + // The rule's own closing brace is the only `}` immediately + // followed by a newline (each keyframe entry's own closing + // brace is instead followed by a space), so find that rather + // than the first `}`, which would only capture the first + // (possibly single-opacity) entry. + let end_idx = block + .find("}\n") + .expect("keyframes rule must have a closing brace"); + &block[..=end_idx] + }; + + for edge_info in &ix_edges { + let edge_id_with_hyphens = edge_info.edge_id.as_str().replace('_', "-"); + + let halo_key = Id::try_from(format!("{}__halo", edge_info.edge_id.as_str())).unwrap(); + let halo_classes = svg_elements + .tailwind_classes + .get(&halo_key) + .unwrap_or_else(|| { + panic!( + "Expected halo tailwind classes for interaction edge {:?}", + edge_info.edge_id + ) + }); + let halo_animation_name = format!("{edge_id_with_hyphens}--halo-opacity"); + assert!( + halo_classes.contains(&format!("animate-[{halo_animation_name}_")), + "Expected halo classes to reference the halo-opacity animation, got: {halo_classes}" + ); + + let halo_block = keyframes_block(&halo_animation_name); + assert!( + halo_block.contains(&format!("opacity: {HALO_OPACITY_ACTIVE};")), + "Halo keyframes should contain an active ({HALO_OPACITY_ACTIVE}) opacity entry, \ + got: {halo_block}" + ); + assert!( + halo_block.contains(&format!("opacity: {HALO_OPACITY_INACTIVE};")), + "Halo keyframes should contain an inactive ({HALO_OPACITY_INACTIVE}) opacity \ + entry, got: {halo_block}" + ); + + let halo_outline_key = + Id::try_from(format!("{}__halo_outline", edge_info.edge_id.as_str())).unwrap(); + let halo_outline_classes = svg_elements + .tailwind_classes + .get(&halo_outline_key) + .unwrap_or_else(|| { + panic!( + "Expected halo outline tailwind classes for interaction edge {:?}", + edge_info.edge_id + ) + }); + let halo_outline_animation_name = + format!("{edge_id_with_hyphens}--halo-outline-opacity"); + assert!( + halo_outline_classes.contains(&format!("animate-[{halo_outline_animation_name}_")), + "Expected halo outline classes to reference the halo-outline-opacity animation, \ + got: {halo_outline_classes}" + ); + + let halo_outline_block = keyframes_block(&halo_outline_animation_name); + assert!( + halo_outline_block.contains(&format!("opacity: {HALO_OUTLINE_OPACITY_ACTIVE};")), + "Halo outline keyframes should contain an active \ + ({HALO_OUTLINE_OPACITY_ACTIVE}) opacity entry, got: {halo_outline_block}" + ); + assert!( + halo_outline_block.contains(&format!("opacity: {HALO_OUTLINE_OPACITY_INACTIVE};")), + "Halo outline keyframes should contain an inactive \ + ({HALO_OUTLINE_OPACITY_INACTIVE}) opacity entry, got: {halo_outline_block}" + ); + } + } + Ok(()) +} + #[test] fn test_svg_edge_infos_dependency_no_arrow_head_animation_classes() -> Result<(), TaffyError> { for svg_elements in build_svg_elements_from_example_ir() { @@ -4890,6 +4998,116 @@ fn test_interaction_edge_halo_forward_reverse_use_distinct_colors() { } } +/// `edge_ix_chain` expands `[t_a, t_b, t_c]` (symmetric kind) into +/// `[t_a->t_b (fwd, 0), t_b->t_c (fwd, 1), t_c->t_b (rev, 2), t_b->t_a (rev, +/// 3)]` (see `InputToIrDiagramMapper::edge_kind_to_edges`). Only the *adjacent* +/// forward/reverse pair (`__1` / `__2`, both on the `t_b`<->`t_c` leg) should +/// highlight each other -- `__0` and `__3` are also a forward/reverse pair on +/// the `t_a`<->`t_b` leg, but are not adjacent in the edge group, so per the +/// halo pairing rule (immediately preceding/following edge only) they must +/// each show only their own active window. +#[test] +fn test_interaction_edge_halo_opacity_pairs_only_adjacent_forward_reverse_edges() { + for svg_elements in + build_svg_elements_for_diagram(INPUT_DIAGRAM_0050_INTERACTION_EDGE_HALO_FORWARD_REVERSE) + { + // Returns the `[min, max]` percentage bounds covered by active + // (`opacity: 0.200;`, i.e. the theme's default `20%` halo opacity * + // `HALO_OPACITY_ACTIVE_MULTIPLIER`) keyframe entries in the given + // edge's halo `@keyframes` rule. + // + // Adjacent edges' keyframe windows always touch (there is no gap + // between edges within a group), so a paired edge's own window + // merges with its neighbour's into a single, wider `[min, max]` + // active span rather than two separate ones -- the pairing signal + // is the *width* of this span, not the count of active windows. + const HALO_OPACITY_ACTIVE: &str = "0.200"; + let active_span = |edge_id: &str| -> (f64, f64) { + let edge_id_with_hyphens = edge_id.replace('_', "-"); + let keyframes_prefix = format!("@keyframes {edge_id_with_hyphens}--halo-opacity"); + let css = &svg_elements.css; + let start_idx = css.find(&keyframes_prefix).unwrap_or_else(|| { + panic!( + "CSS should contain '{keyframes_prefix}', got: {}", + css.as_str() + ) + }); + let block = &css[start_idx..]; + // The rule's own closing brace is the only `}` immediately + // followed by a newline (each keyframe entry's own closing + // brace is instead followed by a space). + let end_idx = block + .find("}\n") + .expect("keyframes rule must have a closing brace"); + let keyframes_block = &block[..=end_idx]; + + // Tokens look like `["12.3%", "{", "opacity:", "0.200;", ...]`; + // collect the percentage preceding every active opacity entry. + let tokens: Vec<&str> = keyframes_block.split_whitespace().collect(); + let active_pcts: Vec = tokens + .windows(4) + .filter_map(|window| { + let [pct_tok, brace, opacity_kw, val_tok] = window else { + unreachable!("windows(4) always yields 4 elements"); + }; + (pct_tok.ends_with('%') + && *brace == "{" + && *opacity_kw == "opacity:" + && val_tok.starts_with(HALO_OPACITY_ACTIVE)) + .then(|| { + pct_tok + .trim_end_matches('%') + .parse::() + .expect("percentage token should parse") + }) + }) + .collect(); + + let min_pct = active_pcts.iter().cloned().fold(f64::INFINITY, f64::min); + let max_pct = active_pcts + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + assert!( + min_pct.is_finite() && max_pct.is_finite(), + "Expected at least one active (opacity: 1.0) keyframe entry for \ + '{keyframes_prefix}', got: {keyframes_block}" + ); + (min_pct, max_pct) + }; + + let span_0 = active_span("edge_ix_chain__0"); + let span_1 = active_span("edge_ix_chain__1"); + let span_2 = active_span("edge_ix_chain__2"); + let span_3 = active_span("edge_ix_chain__3"); + + // `__1` (forward) and `__2` (reverse) are adjacent, so pairing + // merges their windows into one identical `[min, max]` span shared + // by both edges' halos. + assert_eq!( + span_1, span_2, + "Adjacent forward/reverse pair __1 / __2 should share the same merged active span, \ + got __1: {span_1:?}, __2: {span_2:?}" + ); + + // `__0` (forward) is followed by another forward edge (`__1`), and + // `__3` (reverse) is preceded by another reverse edge (`__2`), so + // neither pairs with a neighbour -- their spans stay at their own + // (comparatively narrow) width. + let width = |span: (f64, f64)| span.1 - span.0; + assert!( + width(span_1) > width(span_0) * 1.3, + "Paired edge __1's merged span ({span_1:?}) should be noticeably wider than \ + unpaired edge __0's own span ({span_0:?})" + ); + assert!( + width(span_2) > width(span_3) * 1.3, + "Paired edge __2's merged span ({span_2:?}) should be noticeably wider than \ + unpaired edge __3's own span ({span_3:?})" + ); + } +} + // === Edge group-ID fallback tests === // /// `edge_descs` / `edge_labels` entries keyed by an edge *group* ID (rather From f75690820a7e99e291b014df60c54d0ba2d17cf6 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 20:55:03 +1200 Subject: [PATCH 3/4] Change animation speed to `0.3` seconds per 100 pixels. --- .../taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs index 44a7e0b6..04b06ed0 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs @@ -142,8 +142,8 @@ impl SvgEdgeInfosBuilder { // 5. The `duration` for each edge's animation will be the `total_animation_time // * (edge_length / total_length)`. - /// 0.4 seconds per 100 pixels - const SECONDS_PER_PIXEL: f64 = 0.4 / 100.0; + /// 0.3 seconds per 100 pixels + const SECONDS_PER_PIXEL: f64 = 0.3 / 100.0; // === Global Pass 1: collect metadata and register face contacts === // From 241ab041347d56ac603b4a935972106ff6699fc7 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 20:57:15 +1200 Subject: [PATCH 4/4] Update `CHANGELOG.md`. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e22a88..04a7527a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ * Update `EdgeCurvature::Curved` and `EdgeCurvature::Orthogonal` edge path builders to skip thing description spacers when rank dir spacers are at a larger cross-axis coordinate. ([#62][#62]) * Suggest custom entity types as a completion option when editing `theme_types_styles`. ([#63][#63]) * Suggest custom entity types as a completion option when editing `entity_types..`. ([#63][#63]) +* Interaction edge that is currently animated has its halo more opaque, inactive edges have a more transparent halo. ([#64][#64]) +* Reduce halo outline animation duration to `0.3` seconds per 100 pixels. ([#64][#64]) [#42]: https://github.com/azriel91/disposition/pull/42 [#43]: https://github.com/azriel91/disposition/pull/43 @@ -92,6 +94,7 @@ [#61]: https://github.com/azriel91/disposition/pull/61 [#62]: https://github.com/azriel91/disposition/pull/62 [#63]: https://github.com/azriel91/disposition/pull/63 +[#64]: https://github.com/azriel91/disposition/pull/64 ## 0.3.0 (2026-06-07)