Skip to main content

lib/phoenix_live_gantt.ex

defmodule PhoenixLiveGantt do
  @moduledoc """
  Waterfall (Gantt) view — horizontal bars on a date-range axis.

  Each item is rendered as a row with a horizontal bar whose left edge
  corresponds to its start date and width corresponds to its duration.
  Optionally draws SVG connector lines between dependent items using
  orthogonal (right-angle) routing — the industry-standard approach for
  Gantt dependency arrows.

  ## Features

  - Multi-zoom: `:day`, `:week`, `:month` granularity
  - Today marker (vertical line)
  - Non-working day shading via `day_markers`
  - Progress indicator (fill percentage via `extra.progress_pct`)
  - Milestones (zero-duration items rendered as diamonds)
  - Orthogonal dependency connectors with arrow heads
  - Configurable bar labels — inside / outside / fit / watermark (`label_position`)
  - Sticky sidebar label column on horizontal scroll
  - Grouping via `extra.group` field
  - Custom item rendering via `:item` slot

  ## Data mapping

  Waterfall uses the standard `PhoenixLiveGantt.Task` struct:

  | Waterfall concept | Event field |
  |-------------------|------------|
  | Task name         | `title` |
  | Start date        | `start` (Date) |
  | End date          | `end` (Date, exclusive) |
  | Duration          | Computed from start/end |
  | Status/color      | `color`, `status` |
  | Progress          | `extra.progress_pct` (0–100) |
  | Group/phase       | `extra.group` or `category` |
  | Assignee          | `extra.assignee` |
  | Milestone         | When start == end (zero duration) |

  Connectors are passed separately as a list of
  `%{from: event_id, to: event_id}` maps.

  ## Coordinate system

  Everything is pixel-based against a fixed content width
  (`total_days × day_width_px + 2 × axis_pad`, where `axis_pad` is a fixed
  16px margin on each side that gives edge connectors room — see
  `axis_pad_px/0`). This keeps bar positions, grid columns, today marker, and
  connector arrows aligned no matter the flex context.
  """

  use Phoenix.Component

  alias Phoenix.LiveView.JS
  alias PhoenixLiveGantt.PathFormat
  alias PhoenixLiveGantt.Utils.{I18n, Safe}

  # Row heights in pixels (matches default row_height attr of "2.5rem" = 40px)
  @default_row_px 40
  @group_header_px 28
  # Horizontal breathing room (px) reserved on EACH side of the time axis so a
  # connector exiting/entering a task at the very edge of the window — its
  # exit/entry stub bulges ~@elbow_px past the bar — has somewhere to draw
  # instead of clipping off the chart edge. The whole px coordinate system is
  # shifted right by this (via `x_px` + `bar_geometry`), `content_width` grows by
  # 2×, and transparent spacer columns hold the margin so the grid stays aligned.
  @axis_pad_px 16
  # Connector routing — preferred elbow stem length.
  @elbow_px 10
  # Hard minimum stem visibility. The bus-preferred elbow is ≥ this, but
  # wide-ish gaps (but not wide enough for full elbow) clamp down here.
  @min_exit_stem_px 6
  # Horizontal clearance a connector's vertical trunk keeps from any bar edge:
  # the comfortable target, and the hard floor it settles for when the gap is
  # tight. A trunk drawn flush along a bar edge reads as part of the bar (hard
  # to follow), so we aim for the target, tighten toward the floor only when
  # forced, and — if not even the floor is reachable — let the trunk pass
  # THROUGH the bar (a visible crossing beats an edge-hug). `maybe_shift_trunk`
  # (which places the trunk) and `forward_path_unfeasible?` (which decides
  # forward-vs-detour) BOTH test reachability against the floor, so the decision
  # and the placement agree — otherwise forward can be greenlit on a trunk the
  # placer can only pierce.
  @trunk_clearance_px 6
  @trunk_min_clearance_px 1
  # The arrow marker is 6px wide with refX=6, so its visible triangle
  # extends ~3.6px west and ~2.4px east of the path endpoint. The approach
  # needs room for BOTH the arrowhead's east extent AND a visible horizontal
  # segment in front of it, otherwise the arrow looks glued to the trunk.
  # 10px = ~6 west arrowhead + a few px line-only.
  @min_approach_px 10
  # Label rendering. Labels are rendered at text-[0.6rem] (~9.6px).
  # Average proportional glyph width at that size is ~5px; we use that
  # as the per-character estimate since SVG isn't measuring real fonts.
  # The label text carries a paint-order=stroke halo for contrast over
  # both bars and lines — no background rect, which would cut through
  # the line on either side of narrow words that don't fill the estimate.
  @label_char_px 5
  # Extra clearance around the label when choosing routing, so the label
  # has breathing room off each end of the segment it sits on.
  @label_clearance_px 10

  @doc """
  Toggles an id in an `expanded` set — convenience for `on_toggle_expand`
  handlers so consumers don't re-write the member?/put/delete boilerplate.

  Normalizes the first argument to a `MapSet` (accepts a `MapSet`, a list, or
  `nil`) and returns a `MapSet`. The id should be the value delivered to your
  handler under the `"event-id"` param key.

      def handle_event("toggle_subproject", %{"event-id" => id}, socket) do
        {:noreply, update(socket, :expanded, &PhoenixLiveGantt.toggle_expanded(&1, id))}
      end
  """
  @spec toggle_expanded(MapSet.t() | list() | nil, term()) :: MapSet.t()
  def toggle_expanded(%MapSet{} = set, id) do
    if MapSet.member?(set, id), do: MapSet.delete(set, id), else: MapSet.put(set, id)
  end

  def toggle_expanded(nil, id), do: MapSet.new([id])
  def toggle_expanded(list, id) when is_list(list), do: toggle_expanded(MapSet.new(list), id)

  @doc """
  A `Phoenix.LiveView.JS` command that scrolls a chart's timeline back to its
  start (leftmost column). Pair it with a "home"/"fit" button whose server
  handler refits the window — the server can't move the scroll, and the built-in
  scroll-to-today only fires when the today marker is in view, so a refit that
  doesn't include today would otherwise leave the timeline scrolled to a stale
  spot. Requires `enable_hooks` + the matching `id` (the `LgAutoScroll` hook
  listens for the dispatched `lg:scroll-start`).

      <button phx-click={
        JS.push("fit_project") |> PhoenixLiveGantt.scroll_to_start("project-gantt-\#{@id}")
      }>Project</button>

  Composes with an existing `JS` command (e.g. a `JS.push/2`); pass it as the
  first argument, or omit it to start a fresh command.
  """
  @spec scroll_to_start(JS.t(), String.t()) :: JS.t()
  def scroll_to_start(js \\ %JS{}, id) when is_binary(id),
    do: JS.dispatch(js, "lg:scroll-start", to: "##{id}")

  @doc """
  Renders a Gantt / waterfall chart — horizontal task bars on a time axis with
  orthogonal dependency connectors, milestones, sub-projects, and a built-in
  popover.

  Pass a list of `PhoenixLiveGantt.Task` structs as `events` and a `Date.Range` as
  `date_range`; everything else is optional. Each attribute below documents its
  own default and behavior. The smallest useful call:

      <PhoenixLiveGantt.gantt events={@tasks} date_range={@range} />

  Note: no stylesheet ships — your app's Tailwind must scan this library as a
  content source (see the README), and the JS hooks (`priv/static/assets/
  phoenix_live_gantt.js`) must be registered for the popover / scroll-to-today.
  """
  attr :events, :list, default: []

  attr :date_range, Date.Range,
    required: true,
    doc:
      "The visible date axis. At `:week` / `:month` column granularity the axis is snapped OUTWARD to whole-week (Mon–Sun) / whole-month boundaries so columns are complete and boundary-aligned — pass a tight, task-fitted range and the chart rounds it out to full weeks/months on its own (bars keep their true dates within the widened axis). Finer granularities use the range as-is. NOTE: the granularity is resolved from the pixel density, so a `day_width_px` override (not the named `zoom`) decides whether snapping applies — a density in the week/month band snaps. Ignored in favor of `window_start`/`window_end` when a positive sub-day window is supplied."

  attr :window_start, NaiveDateTime,
    default: nil,
    doc:
      "Optional sub-day positioning origin. When `window_start`/`window_end` are both `NaiveDateTime`s, the window becomes the axis: it drives the start/end instants, the columns, event partition (in-window vs the `← N earlier / N later →` edge counts), and the today marker — instead of `date_range`'s midnight-to-midnight span. Useful at `:hour`/`:min15`/`:min5` zoom to begin ~1 column before the first task rather than at midnight (a wall of empty pre-task columns). Snap `window_start` to a column-slot boundary so labels land on round clock times. `date_range` remains required as the base axis and is used whenever the window is absent or non-positive, so keep it covering the same span."

  attr :window_end, NaiveDateTime,
    default: nil,
    doc: "Sub-day positioning end instant. See `window_start`."

  attr :zoom, :atom, default: :week
  attr :connectors, :list, default: []

  attr :day_markers, :list,
    default: [],
    doc:
      "Non-working / highlighted day ranges, shaded in the grid. Each is a `%{start_date: Date.t(), end_date: Date.t() | nil, available: boolean()}` (an `end_date` of `nil` means a single day; `available: false` shades it as non-working)."

  attr :day_width_px, :integer,
    default: nil,
    doc:
      "Override the per-zoom pixels-per-day. The natural content width is `total_days * day_width_px + 2 * axis_pad_px()` (the pad reserves room for edge connectors); that is the scroll `min-width`. The chart is responsive and fills wider containers on its own (horizontal coords are percentages), so use this only to tune density / the scroll threshold. `nil` uses the zoom default."

  attr :min_bar_px, :integer,
    default: 0,
    doc:
      "Minimum rendered width (px) for a non-milestone bar. Default `0` — bars reflect their TRUE duration, so a task too short to show at the current zoom is a hairline (or vanishes) until you zoom in. Set e.g. `4` to floor every bar to a visible/clickable sliver at the cost of overstating very short tasks' spans (connectors still attach to the rendered edge). A zero-DURATION task is always a milestone diamond regardless of this value."

  attr :today, :any,
    default: nil,
    doc:
      "Today's date (a `Date`), or a `DateTime`/`NaiveDateTime` for a precise 'now' line — recommended at `:hour` zoom, where the marker lands at the exact time and the current-hour column highlights. Defaults to `Date.utc_today()`."

  attr :row_height, :string,
    default: "2.5rem",
    doc: "Height of each task row, as a CSS length. Drives the chart's vertical density."

  attr :label_width, :string,
    default: "14rem",
    doc: "Width of the sticky sidebar label column (the task-name column), as a CSS length."

  attr :on_event_click, :any,
    default: nil,
    doc:
      "phx-click event name fired when a bar/milestone is clicked (requires `enable_hooks`). The handler receives the event id under the `\"event-id\"` param key (hyphen)."

  # Sub-project expand/collapse. An event becomes a sub-project by
  # carrying `extra.parent_id => "<other-event-id>"` — the parent
  # then renders as a roll-up bar spanning every descendant's date
  # range. ALWAYS include every descendant in `events`; the library
  # detects a sub-project by finding events that point at it, and
  # hides the children of collapsed parents itself. Give a sub-project
  # parent `start: nil, end: nil` so its dates roll up to span its
  # children (explicit parent dates are left as-is and children can
  # then visually overflow the bar).
  attr :expanded, :any,
    default: nil,
    doc:
      "Which sub-projects are expanded (children visible). A `MapSet` or list of expanded event ids, `:all` to expand everything, or `nil` for all collapsed. Collapsed parents' children are hidden and connectors retarget to the visible ancestor."

  attr :on_toggle_expand, :any,
    default: nil,
    doc:
      "phx-click event name fired when a sub-project chevron is toggled. The handler receives the event id under the `\"event-id\"` param key (hyphen). Update your `expanded` set in response — see `PhoenixLiveGantt.toggle_expanded/2`."

  attr :show_progress, :boolean, default: true
  attr :show_today, :boolean, default: true

  # ── Bar label (the default title rendered on/near each bar) ──────────────
  # Three orthogonal knobs. All are ignored when the `:item` slot is supplied
  # (the consumer then owns the bar interior). The sidebar label column always
  # shows the title regardless of these.
  attr :label_position, :atom,
    default: :inside,
    values: [:none, :inside, :outside, :fit, :watermark],
    doc:
      "Where each bar's default title renders. `:none` — no bar label (lean on the sidebar; bars stay clean). `:inside` — overlaid on the bar, clipped or spilling per `label_overflow` (a short bar may show a stub). `:outside` — in the empty track beside the bar, never clipped (best when bars are short). `:fit` — overlaid on the bar but HIDDEN on bars too narrow to show at least `label_fit_ratio` of the label, so you get the label on the bars that fit it and a clean bar (no stub) on the ones that don't. The fit test is a pure-CSS container query against the bar's RENDERED width, so it tracks the responsive fill at every zoom with no JavaScript. `:watermark` — like `:outside` (one copy in the empty track beside the bar, after the bar then flipping to before near the right edge), but rendered big, heavy and italic as a soft annotation (tune the softness with `label_watermark_opacity`). Rendered as a sibling overlay in every mode, so the bar's `overflow-hidden` never clips an `:outside`/`:visible` label."

  attr :label_side, :atom,
    default: :auto,
    values: [:auto, :left, :right],
    doc:
      "Which side / alignment the label takes. For `:outside`: `:right` = just past the bar's end, `:left` = just before its start, `:auto` = after the bar but flips to before when it would run past the right edge. For `:inside`/`:fit`: text alignment within the bar (`:right` hugs the end, `:left`/`:auto` the start)."

  attr :label_overflow, :atom,
    default: :truncate,
    values: [:truncate, :clip, :visible],
    doc:
      "How an `:inside` label handles a bar too narrow for it: `:truncate` clips with an ellipsis (…), `:clip` clips hard (no ellipsis), `:visible` lets the text spill past the bar's edge (not clipped). No effect for `:outside` (never clipped), `:fit` (always truncates the shown ones), or `:none`."

  attr :label_fit_ratio, :float,
    default: 0.5,
    doc:
      "For `label_position: :fit`, the minimum fraction of the label that must fit within the bar's RENDERED width for the label to show at all (0.5 = half, 1.0 = only when it fully fits). Bars narrower than that hide the label entirely instead of showing a clipped stub. Evaluated in the browser via a container query, so it stays correct under the responsive fill at every zoom. The label's width is estimated from its character count, so the threshold is approximate."

  attr :label_watermark_opacity, :float,
    default: 0.5,
    doc:
      "For `label_position: :watermark`, the opacity of the big italic label beside the bar (0–1). Soft by default so it reads as a quiet annotation rather than a loud title."

  attr :show_today_edge, :boolean,
    default: true,
    doc:
      "Show the floating directional `← Today` / `Today →` pill when today is off-screen. Independent of `show_today` (which controls the in-range today line), so you can keep the line but drop the off-screen hint."

  attr :show_connectors, :boolean, default: true

  attr :tiny_bar_px, :integer,
    default: 5,
    doc:
      "When a bar renders narrower than this many SCREEN pixels, a small fixed-size down-triangle marker appears at the task's start to signal a too-small-to-see task. The decision is pure CSS — a container query against the bar's rendered width — so it's server-emitted, correct against the responsive fill + zoom, instant on first paint, and re-resolves on resize with no JavaScript. (Clicking the marker opens the same popover, which does need `enable_hooks`.) Set `0` to disable. Bars themselves stay at their true width — see `min_bar_px`. Assumes a uniform value across charts sharing a page."

  attr :avoid_collisions, :boolean,
    default: true,
    doc:
      "When true, connector trunks are shifted to avoid crossing unrelated bars. Turn off for very large Gantts or when you prefer strict bus alignment."

  attr :label_background, :atom,
    default: :halo,
    values: [:halo, :rect],
    doc:
      "How connector labels render. :halo (default) paints each glyph with a base-100 outline so the line shows between letters. :rect draws a solid base-100 rectangle behind the text — stronger contrast over bars but can leave visible gaps in the line around narrow words."

  # --- Connector styling defaults ---
  #
  # Per-connector fields on the connector map override these when set.
  # Color classes use Tailwind's `text-*` tokens (e.g. `text-success`);
  # the SVG line uses `stroke-current` and the arrowhead `fill-current`, so a
  # single color class drives both. The line keeps the class's alpha (so the
  # default below renders a subtle 50% line), but the arrowHEAD is drawn SOLID —
  # the alpha modifier is stripped (`opaque_class/1`) so the line never shows
  # through a half-transparent head.

  attr :connector_color_class, :string, default: "text-base-content/50"
  attr :connector_stroke_width, :float, default: 1.5
  attr :connector_opacity, :float, default: 1.0
  attr :connector_dasharray, :string, default: "none"

  attr :critical_color_class, :string, default: "text-primary"
  attr :critical_stroke_width, :float, default: 2.25

  attr :invalid_color_class, :string, default: "text-error"
  attr :invalid_stroke_width, :float, default: 2.0
  attr :invalid_dasharray, :string, default: "4 3"

  # --- Connector routing defaults ---
  attr :connector_elbow_px, :integer, default: 10
  attr :connector_bar_clearance_px, :integer, default: 10

  attr :bus_split_offset_pct, :integer,
    default: 40,
    doc:
      "Used by `bus_attach_mode={:type_zoned}`. When a bar side has both incoming and outgoing arrows, this is the % offset from the bar's top edge for outgoing attachment (incoming mirrors). Default 40 → 40%/60% split. Set to 50 to disable the split."

  attr :bus_attach_mode, :atom,
    default: :smart,
    values: [:smart, :type_zoned, :center],
    doc: """
    How arrow endpoints attach to a bar edge when multiple arrows touch it:
      * `:smart` (default) — each arrow's attach y depends on the OTHER end's row.
        Outgoing arrows going DOWN attach to the bar bottom; outgoing arrows going UP
        attach to the bar top. Incoming arrows from ABOVE land on the upper-middle of
        the bar; from BELOW land on the lower-middle. Up to 4 designated y positions
        per side. If only one of these positions is in use → collapses to bar center.
      * `:type_zoned` — outgoing always at top, incoming always at bottom (regardless
        of direction). Uses `bus_split_offset_pct`.
      * `:center` — disable splits entirely; everything attaches at the bar center.
    Per-task override: set `extra.bus_attach_mode` on an event to one of these atoms.
    """

  attr :bus_attach_inner_pct, :integer,
    default: 40,
    doc:
      "Smart mode only. % offset from bar edge for both attach positions. Default 40 → split at 40%/60% of bar height. Smart mode picks one for outgoing (by majority outgoing direction) and the opposite for incoming."

  attr :bus_stagger_outgoing_px, :integer,
    default: 0,
    doc:
      "Stagger trunk x by this many px per lane for arrows in the SAME outgoing bus (multiple outgoing from one source on one side). Default 0 = merged (single trunk). Set to 3-5 to fan out each outgoing arrow into its own visible lane. Per-task override via `extra.bus_stagger_outgoing_px`."

  attr :bus_stagger_incoming_px, :integer,
    default: 0,
    doc:
      "Stagger trunk x by this many px per lane for arrows in the SAME incoming bus (multiple incoming to one target on one side). Default 0 = merged. Per-task override via `extra.bus_stagger_incoming_px`."

  attr :bus_stagger_corner_clearance_px, :integer,
    default: 4,
    doc:
      "When stagger is active and a bar side has multiple arrows, lanes are distributed evenly across the bar's FLAT region (excluding rounded corners) so no arrow emerges from a corner. This sets the corner radius to avoid; default 4 matches Tailwind's `rounded` (4px) on the default `bar_class`. Set to 0 if your bar isn't rounded."

  # --- Task/bar styling defaults ---
  #
  # Each of these stacks onto a stable structural class (e.g. the
  # `lg-bar` marker is always present for CSS hooks and
  # tests). Consumers can replace the styling portion without losing
  # the marker. Defaults mirror the current hardcoded behaviour —
  # overriding with nil or a custom class lets you fully restyle.

  # Main column + label header
  # NOTE: the bottom border + background live on the label-header and the time
  # wrapper (below), NOT here — the sticky header is only as wide as the viewport,
  # so a border on it stops at the scroll edge and vanishes under columns scrolled
  # into view. The label-header + time wrapper always span the full content width.
  attr :main_header_class, :string, default: "flex sticky top-0 z-20"

  # `sticky left-0` pins the corner "Task" cell to the left edge while the time
  # columns scroll horizontally underneath (it already rides the parent's
  # `sticky top-0` for vertical scroll, so the corner stays put on both axes).
  # `z-20` keeps it above the column headers that slide under it; the opaque
  # `bg-base-100` masks them.
  attr :label_header_class, :string,
    default:
      "sticky left-0 z-20 flex-shrink-0 bg-base-100 border-r border-base-content/10 border-b-2 border-b-base-content/15 px-3 py-2 font-semibold text-sm text-base-content"

  attr :column_header_class, :string,
    default: "text-xs text-center py-2 border-r border-base-content/5 font-medium flex-shrink-0"

  attr :column_header_today_class, :string, default: "bg-primary/10 font-bold text-primary"

  # Grid dividers + non-working days
  attr :column_divider_class, :string,
    default: "border-r border-base-content/5 h-full flex-shrink-0"

  attr :non_working_class, :string, default: "bg-base-content/[0.04]"

  # Bar row (the horizontal row in the timeline)
  attr :row_class, :string,
    default: "relative border-b border-base-content/5 hover:bg-base-content/[0.02]"

  # Label column (left-side column holding every item's label row).
  # `sticky left-0` pins the whole sidebar to the left edge during horizontal
  # scroll so task identity stays visible no matter how far the timeline is
  # panned; the opaque `bg-base-100` masks the body content sliding under it.
  # `sticky` still establishes the containing block for the absolutely-positioned
  # label popovers, so they keep anchoring to this column. The stacking z-index
  # (55 — above bars z-10 / connectors z-20 / today z-30 / milestones z-40 /
  # badges z-50, below the bar popover z-60) is applied INLINE in the template,
  # not as a `z-[55]` utility: a correctness-critical sticky-overlay z must not
  # depend on the host's Tailwind scanning the package for an arbitrary value
  # (the README's #1 gotcha — library arbitrary classes get purged).
  attr :label_col_class, :string,
    default: "sticky left-0 flex-shrink-0 bg-base-100 border-r border-base-content/10"

  attr :label_row_class, :string,
    default:
      "flex items-center px-3 border-b border-base-content/5 overflow-hidden cursor-pointer hover:bg-base-content/[0.02]"

  # Group header (both in the label column and as a spacer in the timeline)
  attr :group_header_class, :string,
    default: "flex items-center bg-base-200/50 border-b border-base-content/10 px-3"

  attr :group_header_text_class, :string,
    default: "text-xs font-bold uppercase tracking-wider text-base-content/60 truncate"

  attr :group_spacer_class, :string,
    default: "bg-base-200/50 border-b border-base-content/10 relative"

  # Bar (non-milestone events)
  attr :bar_class, :string,
    default:
      "absolute top-1 bottom-1 rounded cursor-pointer overflow-hidden flex items-center z-10"

  # Applied (additionally) when an event is a sub-project (has
  # children). Pattern fill differentiates the roll-up bar from
  # leaf-task bars without changing color or geometry — consumers
  # still get their `event.color` underneath. Brackets at the bar's
  # ends are added via gradient/border-style overlays.
  attr :bar_subproject_class, :string,
    default:
      "ring-1 ring-base-content/30 ring-offset-0 [background-image:repeating-linear-gradient(135deg,transparent_0_6px,rgba(0,0,0,0.08)_6px_7px)]"

  # SOLID inline `background-color`s applied to:
  #   1. the rectangle in the timeline column behind each EXPANDED
  #      sub-project's contents, and
  #   2. the sidebar label rows for those same events
  # so the sub-project reads as a single colored band across both
  # columns. Pass a LIST of colors and the renderer picks one per
  # nesting depth (top-level parent = index 0, first nested parent
  # = index 1, etc.) so a sub-project inside another sub-project
  # gets a visually distinct color instead of two translucent
  # layers stacking up to an unexpected hue. A single string is
  # accepted for backwards-compat and used at every depth.
  #
  # The defaults are THEME-AWARE: low-opacity daisyUI semantic colors via
  # `color-mix` + the `--color-*` CSS vars, so the band is a subtle tint that
  # adapts to light/dark themes (an opaque light hex would be harsh on a dark
  # canvas and wash out the theme-colored label text). Pass any CSS color value
  # (hex, rgb, or your own `color-mix(...)`) to override.
  attr :subproject_frame_color, :any,
    default: [
      "color-mix(in oklab, var(--color-warning) 15%, transparent)",
      "color-mix(in oklab, var(--color-info) 15%, transparent)",
      "color-mix(in oklab, var(--color-success) 15%, transparent)",
      "color-mix(in oklab, var(--color-secondary) 15%, transparent)"
    ]

  attr :bar_background_class, :string, default: "absolute inset-0 rounded"
  attr :bar_default_color_class, :string, default: "bg-primary"

  # Typography for the bar label only. Positioning, width, vertical centering,
  # overflow (truncate/clip/visible) and on-bar-vs-on-track text color are all
  # derived from `label_position` / `label_side` / `label_overflow` in
  # `bar_label_layout/7`, so this carries just font/size.
  attr :bar_title_class, :string, default: "text-xs font-medium"
  attr :bar_title_cancelled_class, :string, default: "line-through"

  # Progress fill on the bar
  attr :progress_class, :string, default: "absolute inset-y-0 left-0 rounded-l"
  attr :progress_complete_radius_class, :string, default: "rounded-r"
  attr :progress_incomplete_class, :string, default: "bg-base-content/20"
  attr :progress_complete_class, :string, default: "bg-success/40"

  # Milestone (zero-duration events, rendered as rotated square)
  attr :milestone_class, :string, default: "absolute top-1/2 z-40 cursor-pointer w-4 h-4 border-2"

  attr :milestone_default_color_class, :string, default: "bg-primary"
  attr :milestone_status_cancelled_class, :string, default: "opacity-50"

  # Status modifiers applied to bar backgrounds (not milestones)
  attr :status_tentative_class, :string, default: "opacity-60"
  attr :status_cancelled_class, :string, default: "opacity-40"
  attr :status_pending_approval_class, :string, default: "animate-pulse"
  attr :status_no_show_class, :string, default: nil
  attr :status_blocked_class, :string, default: "opacity-60 grayscale"

  # Bar popover (shown on bar click for every bar — carries the full
  # title, plus an optional second row of custom action buttons when
  # `event.extra.actions` is non-empty). The popover anchors to the
  # bar's left edge with `min-width: bar.width_px`, so it visually
  # extends the bar rather than floating separately.
  #
  # Click anywhere outside the popover or its bar closes it (or
  # Escape). Requires the `LgBarPopover` JS hook (auto-
  # registered with the rest of the PhoenixLiveGantt hooks). Action map
  # shape: `%{icon, tooltip, phx_click, phx_value, phx_target, href,
  # label, class, id}`.
  # `z-[60]` puts an open popover above EVERYTHING else in the chart — bars
  # (z-10), the today line (z-30), milestone diamonds (z-40), and badges (z-50).
  # All those share one stacking context (rows are `position: relative` with no
  # z-index, so they don't make their own), so a popover tying at z-40 with the
  # diamonds would lose to a later row's diamond by DOM order and get clipped
  # where it overhangs the row below. The popover is the focused element; it wins.
  attr :bar_popover_class, :string,
    default:
      "absolute z-[60] max-w-md rounded-md shadow-lg border-2 border-base-content overflow-hidden hidden"

  # Title row: matches the bar's vertical metrics (height + 4px inset
  # via top-1 bottom-1 + center alignment) so the title's apparent
  # position doesn't jump when the popover opens. Padding mirrors the
  # bar title's px-2.
  attr :bar_popover_title_class, :string,
    default:
      "flex items-center px-2 text-xs font-medium whitespace-normal break-words leading-tight"

  # Subtitle row (below title): renders when the event has an
  # assignee and/or non-zero progress. Smaller + slightly muted vs.
  # title. Inherits color/text from the popover wrapper so it stays
  # readable on any bar color.
  attr :bar_popover_subtitle_class, :string,
    default: "px-2 pb-1 text-[0.65rem] opacity-80 leading-tight"

  # Actions row picks up the same color + status as the title so the
  # popover reads as one continuous extension of the bar. No top
  # divider — the colored wrapper already runs edge to edge.
  attr :bar_popover_actions_class, :string, default: "flex gap-1 px-2 py-2"

  # Buttons inherit color/text from the popover wrapper. Hover uses a
  # translucent overlay so it works against any bar color.
  attr :bar_action_button_class, :string,
    default:
      "relative inline-flex items-center justify-center w-7 h-7 rounded hover:bg-base-content/15 cursor-pointer"

  # Applied (additionally) when an action carries `disabled: true`.
  # Removes pointer cues + dims the button so it visually reads as
  # non-interactive. Pointer-events-none ensures the click never
  # fires regardless of the underlying element type.
  attr :bar_action_disabled_class, :string,
    default: "opacity-50 cursor-not-allowed pointer-events-none"

  # Label popover (same shape + behavior as bar popover but anchored
  # to the left label column). Click anywhere outside closes it; only
  # one popover (label or bar, anywhere in the chart) is open at a
  # time. Defaults extend slightly past the label column so the
  # popover visually breaks out into the timeline area.
  attr :label_popover_class, :string,
    default:
      "absolute left-2 right-2 z-[60] rounded-md shadow-lg border-2 border-base-content overflow-hidden hidden"

  # Badges (notification-style numbers/text in corners of bars + action
  # buttons). Per-event badges live on `event.extra.badges` (a list of
  # maps); per-action badges on `action.badge` (single map) or
  # `action.badges` (list). Each badge map:
  #
  #   %{
  #     content:    "5",            # required, text/number to display
  #     corner:     :top_right,     # :top_right | :top_left |
  #                                 # :bottom_right | :bottom_left
  #     color:      "bg-error",     # background, default bg-error
  #     text_color: "text-white",   # optional, default infers from color
  #     flash:      true,           # animate-pulse when truthy
  #     class:      "..."           # extra classes
  #   }
  #
  # Bars: badges render as siblings (so the bar's overflow-hidden
  # doesn't clip them). Action buttons: badges render inside the
  # button itself (button gains `relative` so absolute children
  # anchor correctly).
  attr :badge_class, :string,
    default:
      "absolute z-50 inline-flex items-center justify-center px-1 min-w-[1rem] h-4 text-[0.55rem] font-bold rounded-full ring-1 ring-base-100 leading-none pointer-events-none"

  attr :badge_default_color_class, :string, default: "bg-error"

  # Today marker — a vertical line in the body, and a "Today" badge that sits up
  # in the date-header row (not the body) so it can't collide with bars or the
  # too-small-task markers.
  attr :today_marker_line_class, :string,
    default: "absolute top-0 w-0.5 bg-error z-30 pointer-events-none"

  # Appearance only — vertical position is set inline in the template
  # (`bottom: -2px`) so the badge's red body covers the header's `border-b-2` and
  # meets the marker line seamlessly, independent of which CSS the consumer ships.
  attr :today_marker_badge_class, :string,
    default:
      "absolute -translate-x-1/2 z-10 bg-error text-error-content text-[0.6rem] leading-none px-1.5 py-0.5 rounded font-bold whitespace-nowrap pointer-events-none"

  attr :translations, :map,
    default: %{},
    doc:
      "Overrides for the chart's own CHROME strings — toolbar buttons, the Today label, prev/next, edge counts, popover/expand labels, and short month names. Shape: `%{labels: %{atom => String.t()}, month_names_short: %{1..12 => String.t()}}`; anything omitted falls back to the English default. See `PhoenixLiveGantt.Utils.I18n` for the full key list. This does NOT translate task content (titles, assignees, action tooltips) — you pass those already-localized in each `PhoenixLiveGantt.Task`, so they work with any backend (gettext, Cldr, a JSONB multilang column, …)."

  attr :class, :string, default: ""
  attr :dir, :atom, default: :ltr

  # --- Built-in toolbar (optional) ---
  #
  # When `show_header` is true the component renders its own toolbar with
  # zoom switcher, today button, and prev/next navigation. Consumers opt in
  # selectively via `show_zoom_switcher` / `show_today_button` /
  # `show_navigation`, and wire button clicks via the `on_zoom_change` and
  # `on_navigate` callbacks (JS-struct or string event-name, same pattern as
  # `on_event_click`). The today button fires a client-side `lg:scroll-
  # today` dispatch consumed by the `LgAutoScroll` hook; consumers
  # needing server-side behaviour can override with `on_scroll_today`.

  attr :id, :string,
    default: nil,
    doc:
      "Stable DOM id. Required when `show_header` is true OR `auto_scroll_today` is on, so JS dispatches target the right gantt instance when multiple are on the page."

  attr :show_header, :boolean, default: false
  attr :show_zoom_switcher, :boolean, default: true
  attr :show_today_button, :boolean, default: true
  attr :show_navigation, :boolean, default: true
  attr :zooms, :list, default: [:day, :week, :month]
  attr :on_zoom_change, :any, default: nil

  attr :on_navigate, :any, default: nil
  attr :on_scroll_today, :any, default: nil

  attr :toolbar_class, :string,
    default:
      "lg-toolbar flex items-center justify-between gap-3 px-3 py-2 border-b border-base-content/15 bg-base-100"

  # --- Edge indicators (out-of-range event counts) ---
  attr :show_edge_indicators, :boolean, default: true
  attr :on_show_earlier, :any, default: nil
  attr :on_show_later, :any, default: nil

  attr :on_show_today, :any,
    default: nil,
    doc:
      "phx-click event for the off-screen Today hint (shown when `today` is outside `date_range`). Wire it to widen the range / jump to today; if nil the hint is informational only. Requires `show_today`."

  attr :edge_indicator_class, :string,
    default:
      "lg-edge px-2 py-1 rounded-full bg-base-200/95 border border-base-content/10 text-[0.65rem] font-medium text-base-content/70 shadow-sm hover:bg-base-200 transition-colors"

  # --- JS hooks ---
  attr :enable_hooks, :boolean,
    default: false,
    doc:
      "When true, attaches BOTH JS hooks: `LgAutoScroll` on the container (auto-scroll + today button) and `LgBarPopover` on every bar/milestone/label (the click popover + dependency-tree highlight). Requires the PhoenixLiveGantt JS bundle (`priv/static/assets/phoenix_live_gantt.js`, registered as `window.PhoenixLiveGanttHooks`). Leave false if you don't ship the bundle — otherwise the browser logs an \"unknown hook\" error per element."

  attr :auto_scroll_today, :boolean,
    default: true,
    doc:
      "On mount, scroll the timeline so today is horizontally centered (if today is in range and hooks are enabled)."

  slot :item
  slot :label

  slot :toolbar_start,
    doc: "Extra content rendered at the left of the toolbar, after the today/nav buttons."

  slot :toolbar_end,
    doc: "Extra content rendered at the right of the toolbar, after the zoom switcher."

  def gantt(assigns) do
    validate_event_ids!(assigns.events)

    today = assigns.today || Date.utc_today()

    # `day_width_px` overrides the per-zoom default — e.g. a consumer doing
    # fit-to-width passes a px-per-day computed from the measured viewport.
    day_px = assigns.day_width_px || day_width_px(assigns.zoom)
    row_px = parse_row_height(assigns.row_height)
    min_bar_px = assigns.min_bar_px

    # Resolve the positioning axis: a sub-day `window_start`/`window_end` window
    # when supplied, else the (week/month-snapped) whole-day `date_range`. See
    # `resolve_axis/2`. `view = {origin, span_days}` threads it through positioning.
    {range, origin, span_days} = resolve_axis(assigns, day_px)
    total_days = Date.diff(range.last, range.first) + 1

    view = {origin, span_days}
    content_width = round(span_days * day_px) + 2 * @axis_pad_px

    # Build column headers. Thread the resolved `today` (the explicit
    # `today` attr, else `Date.utc_today()`) so the column highlight
    # agrees with the today-marker line — both must use the same notion
    # of "today" or a consumer-supplied `today` highlights nothing.
    granularity = column_zoom_for(day_px, ceil(span_days))

    # A sub-day window (NaiveDateTime origin) MUST build its headers from the
    # window too, or they disagree with the window-positioned bars. Always take
    # the window-column path when the origin is intra-day, using the
    # budget-capped granularity's slot — which may be a whole day or coarser for a
    # wide sub-day window (so columns stay legible instead of smearing thousands of
    # hourly divs). Only a whole-day `Date` origin uses the date-range builders.
    columns =
      case origin do
        %NaiveDateTime{} ->
          window_columns(
            origin,
            span_days,
            day_px,
            slot_minutes(granularity),
            today,
            assigns.translations
          )

        _ ->
          build_columns(range, granularity, day_px, today, assigns.translations)
      end
      |> pad_axis_columns()

    # --- Sub-project rollup (must run before partition) ---
    # A sub-project parent often has `start: nil, end: nil` and relies
    # on children's dates being rolled up to position it. If we partition
    # FIRST, those nil-date parents would be silently dropped before
    # their dates are computed — losing the entire sub-project — so we
    # roll up against the raw event list before classifying by range.
    rolled_up_events =
      assigns.events
      |> build_event_tree()
      |> then(&rollup_subproject_dates(assigns.events, &1))

    # Partition events by whether they overlap the visible POSITIONING window
    # (`view`), NOT `date_range`. These MUST use the same predicate as
    # `bar_geometry/4`: an event admitted here but clipped there returns
    # `%{out_of_range: true}` and the template's `bar.milestone` access crashes
    # (the bug a sub-day `window_start`/`window_end` reintroduced). Out-of-window
    # events are filtered from rendering (no row, no bar) but counted for the
    # edge indicators ("← N earlier / N later →").
    {in_range_events, earlier_count, later_count} =
      partition_events_by_range(rolled_up_events, view)

    # --- Visibility + retargeting ---
    # Re-build the parent/child tree on the in-range subset (children of
    # out-of-range parents become top-level), then filter to only visible
    # events (children of collapsed parents are hidden), and retarget
    # connector endpoints up the tree so arrows pointing to/from hidden
    # children attach to the visible roll-up ancestor instead.
    expanded_set = normalize_expanded(assigns.expanded, in_range_events)
    event_tree = build_event_tree(in_range_events)
    visible = visible_events(in_range_events, event_tree, expanded_set)
    retargeted_connectors = retarget_connectors(assigns.connectors, event_tree, expanded_set)

    # Sort events: topologically within each group, keeping direct dependents
    # adjacent to their sources to minimize arrow crossings. Events can override
    # the computed placement via `extra.order`.
    sorted_events =
      visible
      |> sort_events_for_layout(retargeted_connectors)
      |> cluster_subprojects(event_tree)

    in_range_ids = MapSet.new(sorted_events, & &1.id)

    # Build group boundaries for visual separators
    groups = build_groups(sorted_events)

    # Pre-compute Y position (top pixel) for each row — accounts for group headers
    row_positions = compute_row_positions(sorted_events, groups, row_px)
    total_content_height = row_positions.total_height

    # Bracketing frames for currently-expanded sub-projects (drawn in
    # the timeline column as a translucent rect with thick L/R borders).
    subproject_frames =
      compute_subproject_frames(
        sorted_events,
        event_tree,
        expanded_set,
        row_positions,
        row_px,
        view,
        day_px,
        min_bar_px
      )

    # Index events by id for connector lookups
    events_by_id = Map.new(sorted_events, &{&1.id, &1})

    # Non-working dates from day_markers
    non_working_dates = non_working_dates(assigns.day_markers)

    # Normalize connectors once (apply defaults for :type, :critical, :label).
    # Skip connectors touching any filtered (out-of-range) event — we'd have
    # nothing to anchor them to. In Phase 2 these become edge-markers on the
    # scroll viewport.
    normalized_connectors =
      retargeted_connectors
      |> Enum.filter(fn c ->
        MapSet.member?(in_range_ids, c.from) and MapSet.member?(in_range_ids, c.to)
      end)
      |> Enum.map(&normalize_connector/1)

    # Which side of each bar an arrow attaches to (left/right), so an `:auto`
    # track label (`:outside` / `:watermark`) can dodge to the arrow-free side.
    # Empty when connectors are hidden — nothing to avoid. See `track_position/5`.
    label_arrow_sides =
      if assigns.show_connectors,
        do: compute_label_arrow_sides(normalized_connectors),
        else: %{}

    # Count outgoing/incoming per {event_id, type} so we can route shared
    # stems (bus routing — arrows from same source share exit stem; arrows
    # to same target share entry stem). Keyed by type to avoid collapsing
    # buses across dependency kinds that leave different bar edges.
    {outgoing_count, incoming_count} = count_connector_endpoints(normalized_connectors)

    # Per-{event_id, side} tally — counts arrows by attach class.
    # Four classes per side: out_up, out_down, in_above, in_below
    # (where "above/below" describes the OTHER end's row position).
    # Used by smart-mode attachment to decide which of the 4 designated y
    # positions an arrow lands at, and to collapse to bar center when only
    # one class is present on a side.
    side_tally = count_per_side(normalized_connectors, row_positions)

    # Lane assignment for stacked backward :fs arrows (multiple invalid
    # deps from the same source + direction) so they don't draw on top
    # of each other.
    backward_lanes =
      assign_backward_lanes(
        normalized_connectors,
        events_by_id,
        row_positions,
        view,
        day_px,
        min_bar_px
      )

    # Lane assignment for FORWARD bus stagger. Per-{event_id, side, direction}
    # bus, sorts members by other-end row position and assigns lane indices
    # 0..N-1. Used by `stagger_x_offset/3` to spread merged-bus arrows into
    # their own trunk x's when `bus_stagger_outgoing_px` /
    # `bus_stagger_incoming_px` (or per-task overrides) are non-zero.
    bus_lanes = assign_bus_lanes(normalized_connectors, row_positions)

    # Bar obstacle map for collision-aware trunk routing — skipped when
    # `avoid_collisions` is disabled so large Gantts pay zero cost.
    bar_obstacles =
      if assigns.avoid_collisions,
        do: compute_bar_obstacles(sorted_events, row_positions, view, day_px, row_px, min_bar_px),
        else: []

    # Bundle styling defaults so resolve_style/3 can pick the category
    # (normal / critical / invalid) and then let per-connector overrides
    # take priority.
    style_defaults = %{
      normal: %{
        color_class: assigns.connector_color_class,
        stroke_width: assigns.connector_stroke_width,
        opacity: assigns.connector_opacity,
        dasharray: assigns.connector_dasharray
      },
      critical: %{
        color_class: assigns.critical_color_class,
        stroke_width: assigns.critical_stroke_width,
        opacity: assigns.connector_opacity,
        dasharray: assigns.connector_dasharray
      },
      invalid: %{
        color_class: assigns.invalid_color_class,
        stroke_width: assigns.invalid_stroke_width,
        opacity: assigns.connector_opacity,
        dasharray: assigns.invalid_dasharray
      }
    }

    # Bundle the ambient routing context so the per-connector builders
    # don't have to thread 8+ positional args each.
    connector_ctx = %{
      events_by_id: events_by_id,
      row_positions: row_positions,
      range: range,
      view: view,
      day_px: day_px,
      min_bar_px: min_bar_px,
      row_px: row_px,
      content_width: content_width,
      outgoing_count: outgoing_count,
      incoming_count: incoming_count,
      side_tally: side_tally,
      backward_lanes: backward_lanes,
      bus_lanes: bus_lanes,
      bars: bar_obstacles,
      avoid_collisions: assigns.avoid_collisions,
      style_defaults: style_defaults,
      elbow_px: assigns.connector_elbow_px,
      bar_clearance_px: assigns.connector_bar_clearance_px,
      bus_split_offset_pct: assigns.bus_split_offset_pct,
      bus_attach_mode: assigns.bus_attach_mode,
      bus_attach_inner_pct: assigns.bus_attach_inner_pct,
      bus_stagger_outgoing_px: assigns.bus_stagger_outgoing_px,
      bus_stagger_incoming_px: assigns.bus_stagger_incoming_px,
      bus_stagger_corner_clearance_px: assigns.bus_stagger_corner_clearance_px
    }

    # Compute connector paths (list of %{d, from_id, to_id, type, critical,
    # invalid, label, label_x, label_y})
    connector_paths =
      if assigns.show_connectors,
        do: compute_connector_paths(normalized_connectors, connector_ctx),
        else: []

    assigns =
      assigns
      |> assign(:today, today)
      |> assign(:total_days, total_days)
      |> assign(:view, view)
      |> assign(:day_px, day_px)
      |> assign(:min_bar_px, min_bar_px)
      |> assign(:content_width, content_width)
      |> assign(:content_height, total_content_height)
      |> assign(:row_px, row_px)
      |> assign(:columns, columns)
      |> assign(:sorted_events, sorted_events)
      |> assign(:groups, groups)
      |> assign(:row_positions, row_positions)
      |> assign(:non_working_dates, non_working_dates)
      |> assign(:connector_paths, connector_paths)
      |> assign(:label_arrow_sides, label_arrow_sides)
      |> assign(:event_tree, event_tree)
      |> assign(:expanded_set, expanded_set)
      |> assign(:subproject_frames, subproject_frames)
      |> assign(:earlier_count, earlier_count)
      |> assign(:later_count, later_count)
      |> assign(:today_offscreen, today_offscreen_side(today, view))

    ~H"""
    <div class="lg-wrap relative" dir={to_string(@dir)}>
      <%!-- Self-contained fade rule used by the LgBarPopover hook
         when a popover opens — every bar/label/connector outside the
         active task's dependency tree gets `lg-faded` applied.
         Inline so the feature works even when consumers don't import
         the package CSS.

         Opacity ONLY — deliberately no `filter: grayscale`. A popover on a
         large chart fades a few hundred elements at once; a per-element CSS
         `filter` makes each its own compositor layer and repaints them on every
         scroll frame (visible jank while a popover is open). `opacity` is
         compositor-cheap and doesn't repaint on scroll, and at 0.3 a colored bar
         already blends most of the way to the background, so it still reads as
         "inactive" without the layer-explosion cost. --%>
      <style>
        .lg-faded {
          opacity: 0.3 !important;
          transition: opacity 150ms ease;
        }

        /* Defensive guarantee: anything explicitly pinned by the
           LgBarPopover hook (the active task's bar, label,
           badges, popover) must NEVER appear faded — the JS adds
           `lg-pinned` to every element whose data-event-id
           matches the active task, regardless of what any other
           selector does. */
        .lg-pinned {
          opacity: 1 !important;
        }

        /* Smooth slide for bottom-corner badges that the popover hook
           pushes down when its popover opens (and back up when it
           closes). Only `transform` is animated — `top` stays at its
           original computed value. */
        .lg-bar-badge[data-badge-corner^="bottom_"] {
          transition: transform 150ms ease;
        }

        /* Connector labels render as HTML in a non-stretched overlay (not SVG
           text inside the `preserveAspectRatio="none"` shaft, which distorted
           them). A stacked base-100 text-shadow gives the same readable halo the
           SVG `stroke` used, so the label stays legible over bars/lines. */
        .lg-connector-label {
          text-shadow:
            0 0 2px var(--color-base-100), 0 0 2px var(--color-base-100),
            0 0 2px var(--color-base-100), 0 0 2px var(--color-base-100);
        }
      </style>

      <%!-- "Too small to see" markers, decided in PURE CSS via a container
         query — no JavaScript measurement. Each marker sits inside a per-task
         container whose width tracks the bar's RENDERED width (same `%`, so it
         stretches with the responsive fill). The browser reveals the marker
         whenever that rendered width is at/under `tiny_bar_px`, and
         re-evaluates on resize automatically — so the decision is server-emitted
         + browser-resolved against true screen pixels, instant on first paint,
         no socket/hook needed. The threshold is `tiny_bar_px` (an integer attr,
         so safe to interpolate); injected raw because HEEx treats `<style>`
         bodies as opaque text (CSS braces aren't interpolation). Assumes a
         uniform `tiny_bar_px` across charts sharing a page. --%>
      <%= if @tiny_bar_px > 0 do %>
        {Phoenix.HTML.raw(
          "<style>.lg-tiny-marker{display:none}@container (max-width:#{@tiny_bar_px}px){.lg-tiny-marker{display:block}}</style>"
        )}
      <% end %>

      <%!-- Optional built-in toolbar (zoom switcher, today, prev/next).
           Sits above the scroll container so it doesn't scroll horizontally.
           Callbacks are wired by the consumer; the today button defaults to
           a JS.dispatch that the LgAutoScroll hook handles. That hook is only
           attached when `enable_hooks` is set, so the default scroll-to-today
           needs BOTH `id` and `enable_hooks` — otherwise the dispatch has no
           listener. A custom `on_scroll_today` works without hooks. The button
           is disabled (not silently dead) when neither path can fire. --%>
      <div :if={@show_header} class={@toolbar_class}>
        <div class="flex items-center gap-2">
          <button
            :if={@show_today_button}
            type="button"
            class="btn btn-xs btn-ghost lg-today-btn"
            phx-click={today_click_handler(@id, @on_scroll_today)}
            disabled={not today_button_functional?(@on_scroll_today, @id, @enable_hooks)}
            title={
              if not today_button_functional?(@on_scroll_today, @id, @enable_hooks),
                do: I18n.label(:today_scroll_disabled, @translations)
            }
          >
            {I18n.label(:today, @translations)}
          </button>
          <div :if={@show_navigation and not is_nil(@on_navigate)} class="join">
            <button
              type="button"
              class="btn btn-xs btn-ghost join-item lg-nav-prev"
              phx-click={@on_navigate}
              phx-value-direction="prev"
              aria-label={I18n.label(:prev, @translations)}
            ></button>
            <button
              type="button"
              class="btn btn-xs btn-ghost join-item lg-nav-next"
              phx-click={@on_navigate}
              phx-value-direction="next"
              aria-label={I18n.label(:next, @translations)}
            ></button>
          </div>
          {render_slot(@toolbar_start)}
        </div>
        <div class="flex items-center gap-2">
          <div
            :if={@show_zoom_switcher and not is_nil(@on_zoom_change)}
            class="join lg-zoom"
            role="group"
          >
            <button
              :for={z <- @zooms}
              type="button"
              class={[
                "btn btn-xs join-item",
                if(@zoom == z, do: "btn-primary", else: "btn-ghost")
              ]}
              phx-click={@on_zoom_change}
              phx-value-zoom={to_string(z)}
              aria-pressed={to_string(@zoom == z)}
            >
              {zoom_label(z, @translations)}
            </button>
          </div>
          {render_slot(@toolbar_end)}
        </div>
      </div>

      <%!-- Edge indicators — absolute-positioned pills that pin to the
           left/right of the wrap, staying in the viewport as the user
           scrolls the timeline horizontally. Clickable when the consumer
           wires `on_show_earlier` / `on_show_later`; otherwise rendered as
           informational badges (disabled button, no pointer events). --%>
      <button
        :if={@show_edge_indicators and @earlier_count > 0}
        type="button"
        class={["absolute left-2 z-40 lg-edge-earlier", @edge_indicator_class]}
        style={"top: #{edge_indicator_top_px(@show_header)}px"}
        phx-click={@on_show_earlier}
        disabled={is_nil(@on_show_earlier)}
        title={I18n.label(:earlier_tasks, @translations, %{count: @earlier_count})}
      >{I18n.label(:earlier_tasks, @translations, %{count: @earlier_count})}
      </button>
      <button
        :if={@show_edge_indicators and @later_count > 0}
        type="button"
        class={["absolute right-2 z-40 lg-edge-later", @edge_indicator_class]}
        style={"top: #{edge_indicator_top_px(@show_header)}px"}
        phx-click={@on_show_later}
        disabled={is_nil(@on_show_later)}
        title={I18n.label(:later_tasks, @translations, %{count: @later_count})}
      >
        {I18n.label(:later_tasks, @translations, %{count: @later_count})}</button>

      <%!-- Off-screen today hint. When `today` falls outside `date_range`, we
           DON'T widen the axis to reach it — instead a directional pill pins to
           the edge pointing toward today. Sits below the edge-task indicators so
           the two never overlap. Clickable when `on_show_today` is wired
           (e.g. to widen the range / jump to today); otherwise informational. --%>
      <button
        :if={@show_today and @show_today_edge and @today_offscreen == :before}
        type="button"
        class={["absolute left-2 z-40 lg-today-edge", @edge_indicator_class]}
        style={"top: #{edge_indicator_top_px(@show_header) + 32}px"}
        phx-click={@on_show_today}
        disabled={is_nil(@on_show_today)}
        title={I18n.label(:today, @translations)}
      >{I18n.label(:today, @translations)}
      </button>
      <button
        :if={@show_today and @show_today_edge and @today_offscreen == :after}
        type="button"
        class={["absolute right-2 z-40 lg-today-edge", @edge_indicator_class]}
        style={"top: #{edge_indicator_top_px(@show_header) + 32}px"}
        phx-click={@on_show_today}
        disabled={is_nil(@on_show_today)}
        title={I18n.label(:today, @translations)}
      >
        {I18n.label(:today, @translations)}</button>

      <div
        id={@id}
        class={["lg-chart overflow-x-auto bg-base-100", @class]}
        phx-hook={if @enable_hooks, do: "LgAutoScroll"}
        data-auto-scroll-today={to_string(@auto_scroll_today)}
      >
        <%!-- `min-w-full` makes the chart at least the viewport width (so a
             short timeline fills it) while still growing past it (and
             scrolling) when the natural content is wider. The timeline parts
             below `flex-1` + `min-width: content_width` to realize the
             fill-vs-scroll, all in CSS — no measurement, no round-trip. --%>
        <%!-- `width: max-content` sizes the wrapper to the FULL chart width (label
             column + the timeline's `content_width`), not just the viewport.
             Without it the header/body rows are only viewport-wide and the
             timeline merely overflows them — so the sticky left column has only
             ~one viewport of travel before it hits its parent's right edge and
             unsticks mid-scroll (visible at fine zooms where the content far
             exceeds the viewport). `min-w-full` still floors a short chart at the
             viewport so the timeline's `flex-1` fills it. Inline (not a `w-max`
             utility) so this load-bearing width can't be purged by a host that
             doesn't scan the package. --%>
        <div class="flex flex-col relative min-w-full" style="width: max-content">
          <%!-- Column headers --%>
          <div class={["lg-header", @main_header_class]}>
            <%!-- Label column header --%>
            <div
              class={["lg-label-header", @label_header_class]}
              style={"width: #{Safe.sanitize_css_dimension(@label_width, "14rem")}"}
            >
              {I18n.label(:task, @translations)}
            </div>

            <%!-- Time columns. The bottom border + bg sit HERE (not on the
                 sticky header) so they span the full content width and stay put
                 under columns scrolled into view. --%>
            <div
              class="flex flex-1 bg-base-100 border-b-2 border-base-content/15 relative"
              style={"min-width: #{@content_width}px"}
            >
              <div
                :for={col <- @columns}
                class={
                  unless col[:spacer] do
                    [
                      "lg-col-header",
                      @column_header_class,
                      col.is_today && @column_header_today_class
                    ]
                  end
                }
                style={"width: #{pct(col.width_px, @content_width)}%"}
              >
                {col.label}
              </div>

              <%!-- "Today" badge lives HERE, in the date-header row, anchored to
                   the marker's x — so it can't collide with bars or the
                   too-small-task triangles down in the body. The vertical line
                   itself continues below in the body. --%>
              <div
                :if={@show_today && today_in_range?(@today, @view)}
                class={["lg-today-badge", @today_marker_badge_class]}
                style={"left: #{pct(today_left_px(@today, @view, @day_px), @content_width)}%; bottom: -2px"}
              >
                {I18n.label(:today, @translations)}
              </div>
            </div>
          </div>

          <%!-- Body. `isolate` puts the whole body in its own stacking context so
               its internal z-indexes (bars z-10 … badges z-50 … popover z-60) stay
               LOCAL and can't paint over the sticky column header (z-20) when the
               chart is scrolled vertically. Without it, milestones/badges/etc.
               outrank the header and bleed on top of it. --%>
          <div class="flex relative isolate">
            <%!-- Label column (left). `z-index: 55` (inline — see the
                 `label_col_class` attr docs) keeps the sticky sidebar above the
                 body layers that scroll under it. --%>
            <div
              class={@label_col_class}
              style={"width: #{Safe.sanitize_css_dimension(@label_width, "14rem")}; z-index: 55"}
            >
              <%= for {event, idx} <- Enum.with_index(@sorted_events) do %>
                <%!-- Group header row (inside label column) --%>
                <div
                  :if={show_group_header?(@groups, event, idx)}
                  class={["lg-group", @group_header_class]}
                  style={"height: #{@row_positions.group_header_px}px"}
                  data-group={get_group(event)}
                >
                  <span class={@group_header_text_class}>
                    {get_group(event) || I18n.label(:ungrouped, @translations)}
                  </span>
                </div>

                <% label_id = label_dom_id(@id, event.id) %>
                <% label_pop_id = label_popover_dom_id(@id, event.id) %>

                <% in_open_subproject? =
                  in_open_subproject?(event, @event_tree, @expanded_set) %>
                <% event_depth = depth_of(event.id, @event_tree) %>
                <%!-- 2 px offset so the rightmost guide line (drawn as a
                   `border-l-2`) stays uncovered to the LEFT of the bg.
                   `+ 12` so the bg also clears the chevron's own column
                   on the right side of the lines, leaving the chevron
                   visible against the row's normal background. --%>
                <% bg_start_px = max(0, event_depth - 1) * 12 + 14 %>
                <% row_color = frame_color_for(@subproject_frame_color, event_depth - 1) %>

                <%!-- Item label — clickable, opens the label popover --%>
                <div
                  id={label_id}
                  class={["lg-label", @label_row_class]}
                  style={
                    [
                      "height: #{@row_px}px",
                      in_open_subproject? &&
                        "background: linear-gradient(to right, transparent 0, transparent #{bg_start_px}px, #{row_color} #{bg_start_px}px)"
                    ]
                    |> Enum.filter(& &1)
                    |> Enum.join("; ")
                  }
                  data-event-id={event.id}
                  data-group={get_group(event)}
                  data-parent-id={parent_id_of(event)}
                  phx-hook={@enable_hooks && "LgBarPopover"}
                  tabindex={@enable_hooks && "0"}
                  role={@enable_hooks && "button"}
                  aria-haspopup={@enable_hooks && "dialog"}
                  data-popover-target={label_pop_id}
                >
                  <.subproject_chevron
                    event={event}
                    tree={@event_tree}
                    expanded={@expanded_set}
                    on_toggle={@on_toggle_expand}
                    translations={@translations}
                  />
                  <%= if @label != [] do %>
                    {render_slot(@label, event)}
                  <% else %>
                    <.default_label event={event} translations={@translations} />
                  <% end %>
                </div>

                <%!-- Label popover — same shape as the bar popover but
                   anchored to the label row's y. Sibling of the row,
                   positioned absolutely against the label column (which
                   is `relative`).

                   `phx-update="ignore"` matches the bar popover so the
                   JS-applied `hidden` class survives LiveView diffs. --%>
                <div
                  id={label_pop_id}
                  class={["lg-label-popover", @label_popover_class]}
                  style={label_popover_style(@row_positions, event.id, @row_px)}
                  data-popover-for={label_id}
                  phx-update="ignore"
                  role="dialog"
                  aria-label={I18n.label(:details_for, @translations, %{title: event.title})}
                >
                  <div class={[
                    event.color || @bar_default_color_class,
                    event.text_color || Safe.infer_text_color(event.color),
                    event.status == :tentative && @status_tentative_class,
                    event.status == :cancelled && @status_cancelled_class,
                    event.status == :pending_approval && @status_pending_approval_class,
                    event.status == :no_show && @status_no_show_class,
                    event.status == :blocked && @status_blocked_class,
                    event.class
                  ]}>
                    <div
                      class={[
                        "lg-label-popover-title",
                        event.status == :cancelled && @bar_title_cancelled_class,
                        @bar_popover_title_class
                      ]}
                      style={"min-height: #{@row_px - 8}px"}
                    >
                      {event.title || I18n.label(:no_title, @translations)}
                    </div>
                    <% label_subtitle = bar_subtitle(event) %>
                    <div
                      :if={label_subtitle}
                      class={[
                        "lg-label-popover-subtitle",
                        @bar_popover_subtitle_class
                      ]}
                    >
                      {label_subtitle}
                    </div>
                    <% label_actions =
                      popover_actions(
                        event,
                        @event_tree,
                        @expanded_set,
                        @on_toggle_expand,
                        @translations
                      ) %>
                    <div
                      :if={label_actions != []}
                      class={[
                        "lg-label-popover-actions",
                        @bar_popover_actions_class
                      ]}
                    >
                      <.bar_action_button
                        :for={action <- label_actions}
                        action={action}
                        event_id={event.id}
                        class={@bar_action_button_class}
                        disabled_class={@bar_action_disabled_class}
                      />
                    </div>
                  </div>
                </div>
              <% end %>
            </div>

            <%!-- Bar/timeline column (right). Horizontal coords render as %
                 of this column's width; the px geometry is converted via
                 `pct/2`. `flex-1` + `min-width: content_width` lets it fill the
                 viewport (short chart) or grow + scroll (long chart). --%>
            <div
              class="relative flex-1"
              style={"min-width: #{@content_width}px; height: #{@content_height}px"}
            >
              <%!-- Grid background: column dividers + non-working day shading --%>
              <div class="absolute inset-0 flex pointer-events-none">
                <div
                  :for={col <- @columns}
                  class={
                    unless col[:spacer] do
                      [
                        @column_divider_class,
                        col.non_working && @non_working_class
                      ]
                    end
                  }
                  style={"width: #{pct(col.width_px, @content_width)}%"}
                >
                </div>
              </div>

              <%!-- Today marker line (the "Today" badge sits up in the date-header
                   row — see the header above). --%>
              <div
                :if={@show_today && today_in_range?(@today, @view)}
                class={["lg-today", @today_marker_line_class]}
                style={"left: #{pct(today_left_px(@today, @view, @day_px), @content_width)}%; height: #{@content_height}px"}
              >
              </div>

              <%!-- Sub-project frames: a translucent rectangle that
                 spans each EXPANDED sub-project's roll-up bar PLUS
                 every descendant row, across the sub-project's date
                 range. Renders behind the bars (z-0); bars sit on
                 top at z-10. Color is inline rgba so it's guaranteed
                 to render regardless of Tailwind scanning. --%>
              <div
                :for={frame <- @subproject_frames}
                class="lg-subproject-frame absolute pointer-events-none rounded"
                style={"left: #{pct(frame.left_px, @content_width)}%; top: #{frame.top_y}px; width: #{pct(frame.right_px - frame.left_px, @content_width)}%; height: #{frame.bottom_y - frame.top_y}px; background-color: #{frame_color_for(@subproject_frame_color, frame.parent_depth)}; z-index: #{1 + frame.parent_depth}"}
              >
              </div>

              <%!-- Rows (bars) --%>
              <%= for {event, idx} <- Enum.with_index(@sorted_events) do %>
                <%!-- Empty spacer for group header (pushes bar down) --%>
                <div
                  :if={show_group_header?(@groups, event, idx)}
                  class={["lg-group-spacer", @group_spacer_class]}
                  style={"height: #{@row_positions.group_header_px}px"}
                  data-group={get_group(event)}
                >
                </div>

                <%!-- Bar row --%>
                <div class={@row_class} style={"height: #{@row_px}px"}>
                  <% bar = bar_geometry(event, @view, @day_px, @min_bar_px) %>
                  <% actions =
                    popover_actions(
                      event,
                      @event_tree,
                      @expanded_set,
                      @on_toggle_expand,
                      @translations
                    ) %>
                  <% bar_id = bar_dom_id(@id, event.id) %>
                  <% popover_id = popover_dom_id(@id, event.id) %>

                  <%= if bar.milestone do %>
                    <div
                      id={bar_id}
                      class={[
                        "lg-milestone",
                        @milestone_class,
                        event.color || @milestone_default_color_class,
                        event.status == :cancelled && @milestone_status_cancelled_class,
                        event.class
                      ]}
                      style={"left: #{pct(bar.left_px, @content_width)}%; transform: translate(-50%, -50%) rotate(45deg)"}
                      phx-click={@on_event_click}
                      phx-value-event-id={event.id}
                      phx-hook={@enable_hooks && "LgBarPopover"}
                      tabindex={@enable_hooks && "0"}
                      role={@enable_hooks && "button"}
                      aria-haspopup={@enable_hooks && "dialog"}
                      data-popover-target={popover_id}
                      data-event-id={event.id}
                      data-group={get_group(event)}
                      data-parent-id={parent_id_of(event)}
                      title={event.title}
                    >
                    </div>
                  <% else %>
                    <div
                      id={bar_id}
                      class={[
                        "lg-bar",
                        @bar_class,
                        sub_project?(event, @event_tree) && @bar_subproject_class,
                        event.class
                      ]}
                      style={"left: #{pct(bar.left_px, @content_width)}%; width: #{pct(bar.width_px, @content_width)}%"}
                      phx-click={@on_event_click}
                      phx-value-event-id={event.id}
                      phx-hook={@enable_hooks && "LgBarPopover"}
                      tabindex={@enable_hooks && "0"}
                      role={@enable_hooks && "button"}
                      aria-haspopup={@enable_hooks && "dialog"}
                      data-popover-target={popover_id}
                      data-event-id={event.id}
                      data-group={get_group(event)}
                      data-parent-id={parent_id_of(event)}
                      title={bar_title(event)}
                    >
                      <%!-- Background --%>
                      <div class={[
                        @bar_background_class,
                        event.color || @bar_default_color_class,
                        event.status == :tentative && @status_tentative_class,
                        event.status == :cancelled && @status_cancelled_class,
                        event.status == :pending_approval && @status_pending_approval_class,
                        event.status == :no_show && @status_no_show_class,
                        event.status == :blocked && @status_blocked_class
                      ]}>
                      </div>

                      <%!-- Progress fill --%>
                      <div
                        :if={@show_progress && progress_pct(event) > 0}
                        class={[
                          @progress_class,
                          progress_pct(event) >= 100 && @progress_complete_radius_class,
                          if(progress_pct(event) >= 100,
                            do: @progress_complete_class,
                            else: @progress_incomplete_class
                          )
                        ]}
                        style={"width: #{min(progress_pct(event), 100)}%"}
                      >
                      </div>

                      <%!-- Custom in-bar content (slot only). The DEFAULT title no
                           longer renders inside the bar — a short bar clipped it to
                           "S…". It now renders in the empty track beside the bar (see
                           the `lg-track-label` sibling below the bar/milestone), so a
                           task is readable at any width. A consumer that supplies the
                           `:item` slot still owns the bar's interior. --%>
                      <div :if={@item != []} class="relative z-10 w-full">
                        {render_slot(@item, event)}
                      </div>
                    </div>

                    <%!-- Too-small-to-see marker. A bar whose TRUE width is
                       sub-pixel at the current zoom/fill renders as a hairline
                       (or vanishes). This fixed-size down-triangle, centered on the
                       task's span, signals "a task lives here".

                       The container's width tracks the bar's RENDERED width (same
                       `%`), and `container-type: inline-size` makes it a CSS
                       container-query target — the stylesheet above reveals the
                       inner marker purely when that rendered width is at/under
                       `tiny_bar_px` screen px. No JS measures anything: the server
                       emits the rule, the browser resolves it (and re-resolves on
                       resize). The marker keeps the bar's popover wiring so it's
                       clickable even when the bar is ~0px. The marker sits at the
                       container's mid-point (`left: 50%` of the bar-width
                       container) so it centers over the task rather than poking off
                       its leading edge; for a ~0px bar mid ≈ start, so it stays put.
                       --%>
                    <div
                      :if={@tiny_bar_px > 0}
                      class="lg-tiny-container absolute pointer-events-none"
                      style={"left: #{pct(bar.left_px, @content_width)}%; top: 0; width: #{pct(bar.width_px, @content_width)}%; height: 0; container-type: inline-size"}
                    >
                      <div
                        id={"#{bar_id}-tiny"}
                        class={[
                          "lg-tiny-marker absolute z-30 cursor-pointer pointer-events-auto",
                          event.color || @bar_default_color_class
                        ]}
                        style="left: 50%; top: 2px; width: 10px; height: 7px; transform: translateX(-50%); clip-path: polygon(0 0, 100% 0, 50% 100%)"
                        phx-hook={@enable_hooks && "LgBarPopover"}
                        data-popover-target={popover_id}
                        data-event-id={event.id}
                        title={event.title}
                      >
                      </div>
                    </div>

                    <%!-- Bar badges — siblings of the bar (so the bar's
                       overflow-hidden doesn't clip them). Each one
                       positions itself in a corner of the bar's
                       rectangle. Per-corner stacking offset prevents
                       multiple badges in the same corner from sitting
                       on top of each other. --%>
                    <.bar_badge
                      :for={{badge, corner_index} <- bar_badges_with_offsets(event)}
                      badge={badge}
                      corner_index={corner_index}
                      bar={bar}
                      row_px={@row_px}
                      content_width={@content_width}
                      event_id={event.id}
                      class={@badge_class}
                      default_color={@badge_default_color_class}
                    />
                  <% end %>

                  <%!-- Bar label (the DEFAULT title — skipped when the `:item` slot
                       owns the bar interior). A sibling overlay in every mode, so
                       the bar's overflow-hidden never clips an `:outside` label and
                       an `:inside`/`:visible` one can spill past the bar's end.
                       Placement / alignment / overflow / text color come from
                       `bar_label_layout/8` (driven by `label_position`,
                       `label_side`, `label_overflow`, `label_fit_ratio`); `nil` =
                       `:none` (no label). Vertically centered via `line-height` so
                       truncate stays a reliable single-line block.

                       `:fit` makes the overlay a container-query container and wraps
                       the text in a queryable inner span; the injected `@container`
                       rule below hides that span when the bar renders too narrow to
                       show `label_fit_ratio` of the label — fill-aware, no JS (same
                       mechanism as the too-small-to-see marker). `:watermark` is a
                       single big italic copy beside the bar (like `:outside`). --%>
                  <% bar_title = event.title || I18n.label(:no_title, @translations) %>
                  <% lbl =
                    bar_label_layout(
                      @label_position,
                      @label_side,
                      @label_overflow,
                      %{
                        fit_ratio: @label_fit_ratio,
                        watermark_opacity: @label_watermark_opacity
                      },
                      bar,
                      @content_width,
                      bar_label_est(bar_title),
                      @row_px,
                      Map.get(@label_arrow_sides, event.id, %{left: false, right: false})
                    ) %>
                  <div
                    :if={lbl && @item == [] && bar_title not in [nil, ""]}
                    class={[
                      "lg-bar-label absolute top-0 pointer-events-none",
                      @bar_title_class,
                      lbl.overflow_class,
                      event.status == :cancelled && @bar_title_cancelled_class,
                      lbl.text_color || event.text_color || Safe.infer_text_color(event.color)
                    ]}
                    style={lbl.style}
                    data-event-id={event.id}
                    aria-hidden="true"
                  >
                    <%= if lbl.fit? do %>
                      <span id={"#{bar_id}-fl"} class="block truncate">{bar_title}</span>
                    <% else %>
                      {bar_title}
                    <% end %>
                  </div>
                  <%= if lbl && lbl.fit? && @item == [] && bar_title not in [nil, ""] do %>
                    {Phoenix.HTML.raw(
                      "<style>@container (max-width:#{lbl.fit_bp}px){##{bar_id}-fl{display:none}}</style>"
                    )}
                  <% end %>

                  <%!-- Popover anchored to the bar / milestone's left edge —
                       sibling (not child) so the bar's overflow-hidden doesn't
                       clip it, and rendered for BOTH bars and milestones so a
                       diamond is clickable too (otherwise a milestone shows a
                       cursor-pointer but has nowhere to click to). Always
                       rendered so any task can show its full title; the action
                       row only appears when actions exist. Hidden by default;
                       the LgBarPopover hook toggles `hidden` on click.

                       `phx-update="ignore"` keeps the JS-applied `hidden`
                       class (and any toggled state) from being wiped on
                       every LiveView diff. The popover's content reflects
                       the initial server render — re-rendering would
                       require remounting (e.g. swapping the bar id). --%>
                  <div
                    id={popover_id}
                    class={["lg-bar-popover", @bar_popover_class]}
                    style={popover_style(bar, @row_px, @content_width)}
                    data-popover-for={bar_id}
                    phx-update="ignore"
                    role="dialog"
                    aria-label={I18n.label(:details_for, @translations, %{title: event.title})}
                  >
                    <%!-- Colored wrapper: carries the bar's color +
                         text + status + custom class so BOTH the title
                         row AND the actions row share the look (and
                         pulse together for `:pending_approval`). One
                         visual block — the popover reads as the bar
                         expanding open. --%>
                    <div class={[
                      event.color || @bar_default_color_class,
                      event.text_color || Safe.infer_text_color(event.color),
                      event.status == :tentative && @status_tentative_class,
                      event.status == :cancelled && @status_cancelled_class,
                      event.status == :pending_approval && @status_pending_approval_class,
                      event.status == :no_show && @status_no_show_class,
                      event.status == :blocked && @status_blocked_class,
                      event.class
                    ]}>
                      <div
                        class={[
                          "lg-bar-popover-title",
                          event.status == :cancelled && @bar_title_cancelled_class,
                          @bar_popover_title_class
                        ]}
                        style={"min-height: #{@row_px - 8}px"}
                      >
                        {event.title || I18n.label(:no_title, @translations)}
                      </div>
                      <% subtitle = bar_subtitle(event) %>
                      <div
                        :if={subtitle}
                        class={[
                          "lg-bar-popover-subtitle",
                          @bar_popover_subtitle_class
                        ]}
                      >
                        {subtitle}
                      </div>
                      <div
                        :if={actions != []}
                        class={[
                          "lg-bar-popover-actions",
                          @bar_popover_actions_class
                        ]}
                      >
                        <.bar_action_button
                          :for={action <- actions}
                          action={action}
                          event_id={event.id}
                          class={@bar_action_button_class}
                          disabled_class={@bar_action_disabled_class}
                        />
                      </div>
                    </div>
                  </div>
                </div>
              <% end %>

              <%!-- SVG connector SHAFTS. The viewBox stays in PIXELS (the
                   routing math is unchanged) but the element renders at
                   `width: 100%` with `preserveAspectRatio="none"`, so the
                   px paths stretch horizontally in lockstep with the
                   %-positioned bars (both reduce to frac/total × renderedWidth)
                   and stay aligned at any width. `non-scaling-stroke` keeps line
                   thickness crisp. A stretched LINE is still a correct line, so
                   shafts can stretch — arrowHEADS can't (a stretched triangle is
                   not a triangle), so they render separately below. --%>
              <svg
                :if={@connector_paths != []}
                aria-hidden="true"
                class="lg-connectors absolute top-0 left-0 pointer-events-none z-20 overflow-visible"
                width="100%"
                height={@content_height}
                viewBox={"0 0 #{@content_width} #{@content_height}"}
                preserveAspectRatio="none"
              >
                <path
                  :for={p <- @connector_paths}
                  d={p.d}
                  fill="none"
                  stroke-width={p.stroke_width}
                  stroke-dasharray={p.dasharray}
                  opacity={p.opacity}
                  vector-effect="non-scaling-stroke"
                  class={["lg-connector stroke-current", p.color_class]}
                  data-from-id={p.from_id}
                  data-to-id={p.to_id}
                  data-type={p.type}
                  data-critical={to_string(p.critical)}
                  data-invalid={to_string(p.invalid)}
                />
              </svg>
              <%!-- Arrowhead overlay — a px-faithful layer OUTSIDE the stretched
                   shaft SVG. Each head is anchored by % (so its tip tracks the
                   bar-aligned path end as the chart fills/scrolls) but drawn at a
                   FIXED px size (so it stays a crisp triangle at any fill factor).
                   `color_class` is mirrored from the shaft so head + line recolor
                   together. The inner svg is nudged so its triangle TIP lands
                   exactly on the anchor point. --%>
              <div
                :if={@connector_paths != []}
                class="lg-arrowheads absolute top-0 left-0 w-full pointer-events-none z-20"
                style={"height: #{@content_height}px"}
              >
                <div
                  :for={p <- @connector_paths}
                  class={["lg-arrowhead absolute", p.arrow.variant_class, p.head_color_class]}
                  style={"left: #{pct(p.arrow.tip_x, @content_width)}%; top: #{p.arrow.tip_y}px"}
                  data-from-id={p.from_id}
                  data-to-id={p.to_id}
                >
                  <svg
                    aria-hidden="true"
                    class="absolute block overflow-visible"
                    width={p.arrow.size}
                    height={p.arrow.size}
                    viewBox={"0 0 #{p.arrow.size} #{p.arrow.size}"}
                    style={"left: #{p.arrow.off_x}px; top: #{p.arrow.off_y}px"}
                  >
                    <path d={p.arrow.d} class="fill-current" />
                  </svg>
                </div>
              </div>

              <%!-- Connector LABEL overlay — a non-stretched HTML layer OUTSIDE the
                   shaft SVG, for the same reason as the arrowheads: SVG text inside
                   `preserveAspectRatio="none"` gets horizontally stretched. Each
                   label is anchored by % (x tracks the bar-aligned point) at a
                   fixed px y, centered on the point, and rotated for a vertical
                   segment — so the glyphs stay undistorted at any fill. --%>
              <div
                :if={@connector_paths != []}
                class="lg-connector-labels absolute top-0 left-0 w-full pointer-events-none z-20"
                style={"height: #{@content_height}px"}
              >
                <div
                  :for={p <- @connector_paths}
                  :if={p.label && p.label != ""}
                  class={[
                    "lg-connector-label absolute text-[0.6rem] font-medium leading-none whitespace-nowrap select-none",
                    @label_background == :rect && "bg-base-100 rounded px-0.5",
                    p.color_class
                  ]}
                  style={"left: #{pct(p.label_x, @content_width)}%; top: #{p.label_y}px; transform: translate(-50%, -50%)#{label_rotation(p)}"}
                  data-from-id={p.from_id}
                  data-to-id={p.to_id}
                >
                  {p.label}
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    """
  end

  # -- Sub-project chevron component --
  #
  # Indents the label by `depth * 12px` (one level per nested
  # sub-project) and prepends a clickable chevron when the event is
  # itself a sub-project. Non-sub-project rows get a same-width
  # spacer so labels stay vertically aligned across depths. Clicking
  # the chevron pushes the consumer's `on_toggle_expand` event with
  # `event-id` so they can flip that id in their `expanded` set.

  attr :event, PhoenixLiveGantt.Task, required: true
  attr :tree, :map, required: true
  attr :expanded, :any, required: true
  attr :on_toggle, :any, required: true
  attr :translations, :map, default: %{}

  defp subproject_chevron(assigns) do
    depth = depth_of(assigns.event.id, assigns.tree)

    assigns =
      assigns
      |> assign(:depth, depth)
      |> assign(:is_sub, sub_project?(assigns.event, assigns.tree))
      |> assign(:expanded?, MapSet.member?(assigns.expanded, assigns.event.id))
      |> assign(:depth_columns, if(depth > 0, do: Enum.to_list(1..depth), else: []))

    ~H"""
    <div class="flex-shrink-0 flex items-stretch self-stretch mr-1">
      <%!-- One vertical guide line per nesting depth — visually
         links the child back up to its sub-project parent. --%>
      <div
        :for={_ <- @depth_columns}
        class="flex-shrink-0 w-3 border-l-2 border-base-content/20"
      >
      </div>
      <%!-- Chevron slot (or spacer for leaf rows so labels align).
         Use heroicon SVG (`hero-plus-mini` / `hero-minus-mini`) for
         the +/− glyph — the icons are designed to centre on their
         viewBox, so they always sit dead-centre of the 20 px button
         regardless of font metrics. --%>
      <div class="flex-shrink-0 w-5 flex items-center justify-center">
        <button
          :if={@is_sub}
          type="button"
          class="lg-subproject-chevron inline-flex items-center justify-center w-5 h-5 rounded bg-base-content/10 hover:bg-base-content/25 text-base-content cursor-pointer"
          aria-expanded={to_string(@expanded?)}
          aria-label={
            if @expanded?,
              do: I18n.label(:collapse_subproject, @translations),
              else: I18n.label(:expand_subproject, @translations)
          }
          title={
            if @expanded?,
              do: I18n.label(:collapse_subproject, @translations),
              else: I18n.label(:expand_subproject, @translations)
          }
          phx-click={@on_toggle}
          phx-value-event-id={@event.id}
        >
          <span
            aria-hidden="true"
            class={if @expanded?, do: "hero-minus-mini w-4 h-4", else: "hero-plus-mini w-4 h-4"}
          >
          </span>
        </button>
      </div>
    </div>
    """
  end

  # -- Default label component --

  attr :event, PhoenixLiveGantt.Task, required: true
  attr :translations, :map, default: %{}

  defp default_label(assigns) do
    ~H"""
    <div class="flex items-center gap-2 min-w-0 w-full">
      <div
        :if={@event.color}
        class={["w-2 h-2 rounded-full flex-shrink-0", @event.color]}
      >
      </div>
      <span :if={@event.icon} class="flex-shrink-0 text-xs">{@event.icon}</span>
      <span class={[
        "text-sm truncate flex-1",
        @event.status == :cancelled && "line-through text-base-content/50"
      ]}>
        {@event.title || I18n.label(:no_title, @translations)}
      </span>
      <span
        :if={assignee(@event)}
        class="text-[0.6rem] text-base-content/40 truncate flex-shrink-0"
      >
        {assignee(@event)}
      </span>
    </div>
    """
  end

  # -- Column building (returns columns with pixel widths) --

  # Wrap the time columns with a transparent spacer on each side, so the grid
  # occupies `[@axis_pad_px, content_width - @axis_pad_px]` — matching the
  # coordinate shift in `x_px`/`bar_geometry`. The spacer carries no label,
  # border, or shading: it's pure margin where an edge-of-window connector stub
  # can draw instead of clipping off the chart.
  defp pad_axis_columns(columns) do
    spacer = %{
      label: "",
      width_px: @axis_pad_px,
      is_today: false,
      non_working: false,
      spacer: true
    }

    [spacer | columns] ++ [spacer]
  end

  # Per-hour columns (24 per day). The day's date is shown on the 00:00 column,
  # the hour number on the rest. The column matching `now` (when `today` carries
  # a time) is flagged `is_today` so the current hour highlights.
  defp build_columns(range, :hour, day_px, today, tr) do
    hour_px = round(day_px / 24)
    now = if match?(%DateTime{}, today) or match?(%NaiveDateTime{}, today), do: today, else: nil

    for date <- range, hour <- 0..23 do
      %{
        label:
          if(hour == 0,
            do: "#{I18n.month_name_short(date.month, tr)} #{date.day}",
            else: "#{hour}"
          ),
        width_px: hour_px,
        is_today: hour_is_now?(date, hour, now),
        non_working: Date.day_of_week(date) in [6, 7]
      }
    end
  end

  # Sub-hour columns: 15-minute (96/day) and 5-minute (288/day) slots labelled
  # with clock times (7:00, 7:15, …) instead of the meaningless hour ordinal.
  # The day's date sits on the 00:00 slot. `:min5` labels only every third slot
  # (the 15-minute boundaries) so the 5-minute gridlines don't crowd into an
  # unreadable wall of text.
  defp build_columns(range, :min15, day_px, today, tr),
    do: sub_hour_columns(range, day_px, today, 15, 1, tr)

  defp build_columns(range, :min5, day_px, today, tr),
    do: sub_hour_columns(range, day_px, today, 5, 3, tr)

  defp build_columns(range, :day, day_px, today, _tr) do
    today_date = to_date(today)

    Enum.map(range, fn date ->
      %{
        label: "#{date.day}",
        width_px: day_px,
        is_today: date == today_date,
        non_working: Date.day_of_week(date) in [6, 7]
      }
    end)
  end

  defp build_columns(range, :week, day_px, today, tr) do
    today_date = to_date(today)
    dates = Enum.to_list(range)

    # Group by the full ISO week tuple `{iso_year, week}`, NOT `{calendar_year,
    # week}` — `:calendar.iso_week_number/1` returns the ISO year, which differs
    # from the calendar year for a week straddling New Year (Mon 2026-12-28 … Sun
    # 2027-01-03 is ISO week 2026-W53). Keying on the calendar year would split
    # that single week into two mislabeled stubs.
    dates
    |> Enum.chunk_by(fn d -> :calendar.iso_week_number(Date.to_erl(d)) end)
    |> Enum.map(fn chunk ->
      first = hd(chunk)
      days_in_chunk = length(chunk)

      %{
        label: week_label(first, days_in_chunk, tr),
        width_px: days_in_chunk * day_px,
        is_today: today_date in chunk,
        non_working: false
      }
    end)
  end

  defp build_columns(range, :month, day_px, today, tr) do
    today_date = to_date(today)
    dates = Enum.to_list(range)

    dates
    |> Enum.chunk_by(fn d -> {d.year, d.month} end)
    |> Enum.map(fn chunk ->
      first = hd(chunk)
      days_in_chunk = length(chunk)

      %{
        label: month_label(first, tr),
        width_px: days_in_chunk * day_px,
        is_today: today_date in chunk,
        non_working: false
      }
    end)
  end

  defp build_columns(range, _zoom, day_px, today, tr),
    do: build_columns(range, :week, day_px, today, tr)

  defp sub_hour_columns(range, day_px, today, minutes_per_slot, label_every, tr) do
    slots_per_day = div(1440, minutes_per_slot)
    col_px = round(day_px / slots_per_day)
    now = if match?(%DateTime{}, today) or match?(%NaiveDateTime{}, today), do: today, else: nil

    for date <- range, slot <- 0..(slots_per_day - 1) do
      minute_of_day = slot * minutes_per_slot
      h = div(minute_of_day, 60)
      m = rem(minute_of_day, 60)

      label =
        cond do
          slot == 0 -> "#{I18n.month_name_short(date.month, tr)} #{date.day}"
          rem(slot, label_every) == 0 -> "#{h}:#{pad2(m)}"
          true -> ""
        end

      %{
        label: label,
        width_px: col_px,
        is_today: slot_is_now?(date, minute_of_day, minutes_per_slot, now),
        non_working: Date.day_of_week(date) in [6, 7]
      }
    end
  end

  defp pad2(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")

  # Minutes per column slot for a granularity. Drives `window_columns`. Covers
  # the coarse granularities too (a wide sub-day window whose budget-capped
  # granularity demoted to :day/:week/:month) so `window_columns` builds
  # day-or-coarser slots instead of smearing thousands of hourly columns.
  defp slot_minutes(:min5), do: 5
  defp slot_minutes(:min15), do: 15
  defp slot_minutes(:hour), do: 60
  defp slot_minutes(:day), do: 1440
  defp slot_minutes(:week), do: 10_080
  defp slot_minutes(:month), do: 43_200

  # Columns for a sub-day positioning window (origin is a NaiveDateTime partway
  # through a day, not a midnight Date). `build_columns`/`sub_hour_columns`
  # enumerate whole days from a `Date.Range`; here we instead walk fixed
  # `minutes_per_slot` steps from the origin across the window span. Labels match
  # the whole-day builders: the date on each midnight (or day-or-coarser) slot, a
  # bare hour on `:hour` zoom, and the `:15` clock boundaries on sub-hour zooms
  # (the in-between 5-minute gridlines stay blank). The consumer snaps
  # `window_start` to a slot boundary so these labels land on round times.
  #
  # `minutes_per_slot` may be sub-day (5/15/60) OR day-or-coarser (1440/10080/
  # 43200) when a wide window's budget-capped granularity demotes. For the coarse
  # slots we mirror the date-range `:day`/`:week`/`:month` builders: weekend
  # shading only on day-or-finer slots, today-highlight by whole-day containment,
  # and exact-tiling widths (cumulative rounding) so a multi-day slot doesn't
  # leave a dead strip or compress the gridlines.
  defp window_columns(%NaiveDateTime{} = origin, span_days, day_px, minutes_per_slot, today, tr) do
    inner_px = round(span_days * day_px)
    slot_days = minutes_per_slot / 1440
    num_slots = max(ceil(span_days / slot_days), 1)
    now = if match?(%DateTime{}, today) or match?(%NaiveDateTime{}, today), do: today, else: nil
    today_date = to_date(today)
    coarse? = minutes_per_slot >= 1440
    slot_day_span = max(div(minutes_per_slot, 1440), 1)

    for i <- 0..(num_slots - 1) do
      slot_dt = NaiveDateTime.add(origin, i * minutes_per_slot, :minute)
      date = NaiveDateTime.to_date(slot_dt)
      minute_of_day = slot_dt.hour * 60 + slot_dt.minute

      # Width = difference of rounded cumulative pixel positions, clamped to the
      # content edge. Columns always sum to exactly `inner_px` — no half-slot
      # drift, dead strip, or overflow even when a slot doesn't divide the span.
      x0 = min(round(i * slot_days * day_px), inner_px)
      x1 = min(round((i + 1) * slot_days * day_px), inner_px)

      label =
        cond do
          coarse? -> "#{I18n.month_name_short(date.month, tr)} #{date.day}"
          minute_of_day == 0 -> "#{I18n.month_name_short(date.month, tr)} #{date.day}"
          minutes_per_slot == 60 -> "#{slot_dt.hour}"
          rem(minute_of_day, 15) == 0 -> "#{slot_dt.hour}:#{pad2(slot_dt.minute)}"
          true -> ""
        end

      is_today =
        if coarse? do
          # The day-or-coarser slot highlights when today falls within its day
          # span — `today_date == date` for a daily slot, "today's week/month" for
          # week/month slots (matching the date-range builders' chunk containment).
          diff = Date.diff(today_date, date)
          diff >= 0 and diff < slot_day_span
        else
          slot_is_now?(date, minute_of_day, minutes_per_slot, now)
        end

      %{
        label: label,
        width_px: x1 - x0,
        is_today: is_today,
        # A multi-day (week/month) column isn't a weekend; only day-or-finer slots
        # carry weekend shading, matching the date-range `:week`/`:month` builders.
        non_working: minutes_per_slot <= 1440 and Date.day_of_week(date) in [6, 7]
      }
    end
  end

  @doc """
  The default pixels-per-day for a zoom level — `:hour` 720, `:day` 40,
  `:week` 24, `:month` 8. Use it as the floor when computing a fit-to-width
  `day_width_px` override, so fitting only ever *widens* (and a long chart still
  scrolls at its natural density).
  """
  @spec default_day_width_px(atom()) :: pos_integer()
  def default_day_width_px(zoom), do: day_width_px(zoom)

  @doc """
  The fixed horizontal margin (px) reserved on EACH side of the time axis so a
  connector exiting/entering a task at the very edge of the window has room to
  draw instead of clipping. The natural content width is
  `total_days × day_width_px + 2 × axis_pad_px()`. A consumer computing a
  fit-to-width `day_width_px` from a measured viewport should subtract
  `2 × axis_pad_px()` first.
  """
  @spec axis_pad_px() :: non_neg_integer()
  def axis_pad_px, do: @axis_pad_px

  # `:hour` zoom makes a day 720px wide (24 × 30px/hour) so intra-day bars and
  # per-hour columns are legible. The wide content scrolls. `:min15` is sized so
  # a 15-min column is ~45px — enough for a `0:45`-style label to breathe rather
  # than four crammed clock times per hour.
  defp day_width_px(:min5), do: 8640
  defp day_width_px(:min15), do: 4320
  defp day_width_px(:hour), do: 720
  defp day_width_px(:day), do: 40
  defp day_width_px(:week), do: 24
  defp day_width_px(:month), do: 8
  defp day_width_px(_), do: 24

  # Resolve the positioning axis as `{range, origin, span_days}`.
  #
  # Normally the whole-day `date_range` (origin = `range.first` midnight, span =
  # total days), snapped OUTWARD to whole weeks/months at :week/:month
  # granularity (see `snap_range_for_columns/2`). A consumer can override with a
  # sub-day `window_start`/`window_end` (NaiveDateTime) so the axis starts partway
  # through a day — e.g. ~1 column before the first task at `:hour`/`:min15`/
  # `:min5` zoom, instead of a wall of empty pre-task columns from midnight. A
  # non-positive window is meaningless — it's ignored, falling back to the
  # whole-day range rather than producing a 0/negative axis (which would flag
  # every bar out-of-range and blank/crash the chart). The sub-day window is
  # positioned intra-day via `window_columns` and is never snapped.
  defp resolve_axis(assigns, day_px) do
    with {%NaiveDateTime{} = ws, %NaiveDateTime{} = we} <-
           {assigns.window_start, assigns.window_end},
         span when span > 0 <- NaiveDateTime.diff(we, ws, :second) / 86_400 do
      {assigns.date_range, ws, span}
    else
      _ ->
        range = snap_range_for_columns(assigns.date_range, day_px)
        {range, range.first, (Date.diff(range.last, range.first) + 1) * 1.0}
    end
  end

  # Snap a whole-day axis OUTWARD to full column boundaries so :week / :month
  # granularity renders complete, boundary-aligned columns instead of ragged
  # partial stubs (a 2-day Sat–Sun column, a mid-week date label). Finer
  # granularities (:day and below) are already 1-day-per-column, so the range is
  # returned unchanged. The granularity is read from the SAME `column_zoom_for/2`
  # the column builder uses, so a coarse density / budget-capped demotion to
  # :week / :month snaps too. Snapping only widens the range by <1 column, so for
  # any realistic span the granularity the builder picks post-snap matches the one
  # decided here. (Only at the extreme column-budget boundary — a multi-decade
  # range — could the few extra days tip the builder one step coarser; columns
  # still tile to content_width via `days_in_chunk * day_px`, so that's a cosmetic
  # label mismatch, never misalignment.) Bars keep their true dates in the axis.
  defp snap_range_for_columns(%Date.Range{} = range, day_px) do
    total_days = Date.diff(range.last, range.first) + 1

    case column_zoom_for(day_px, total_days) do
      :week -> Date.range(beginning_of_week(range.first), end_of_week(range.last))
      :month -> Date.range(Date.beginning_of_month(range.first), Date.end_of_month(range.last))
      _ -> range
    end
  end

  # ISO weeks start on Monday (`Date.day_of_week/1` → Monday = 1 … Sunday = 7).
  defp beginning_of_week(%Date{} = d), do: Date.add(d, -(Date.day_of_week(d) - 1))
  defp end_of_week(%Date{} = d), do: Date.add(d, 7 - Date.day_of_week(d))

  # Choose the COLUMN granularity for a given continuous density (px/day),
  # independent of any named zoom. This is what makes continuous zoom work: the
  # density can sit anywhere between the named presets, and the header columns
  # snap to whatever granularity reads well at that density (the preset px values
  # double as the thresholds, so each granularity's columns stay legibly wide).
  # Capped so a wide range at a fine density doesn't emit tens of thousands of
  # column divs — it steps to a coarser granularity instead (the bars keep their
  # true density; only the gridlines coarsen). Generous enough that the named
  # sub-hour zooms keep their clock-time columns at typical project lengths —
  # 15-min up to ~31 days, 5-min up to ~10 days — before stepping coarser; a
  # few thousand lightweight column divs render fine.
  @column_budget 3000

  defp column_zoom_for(day_px, total_days) do
    by_density =
      cond do
        day_px >= 8640 -> :min5
        day_px >= 4320 -> :min15
        day_px >= 720 -> :hour
        day_px >= 40 -> :day
        day_px >= 16 -> :week
        true -> :month
      end

    cap_columns(by_density, total_days)
  end

  @column_order [:min5, :min15, :hour, :day, :week, :month]

  defp cap_columns(gran, total_days) do
    @column_order
    |> Enum.drop_while(&(&1 != gran))
    |> Enum.find(:month, fn g -> column_count(g, total_days) <= @column_budget end)
  end

  defp column_count(:min5, days), do: days * 288
  defp column_count(:min15, days), do: days * 96
  defp column_count(:hour, days), do: days * 24
  defp column_count(:day, days), do: days
  defp column_count(:week, days), do: ceil(days / 7)
  defp column_count(:month, days), do: ceil(days / 30)

  # A week column's date span, e.g. "Apr 27 – May 3" (cross-month) or "Apr 6 – 12"
  # (within one month). Replaces the old "W18" ISO ordinal — a date range reads
  # without the viewer having to map a week number back to dates. The axis snaps
  # to whole weeks (see `snap_range_for_columns/2`), so `days_count` is normally
  # 7 (Mon–Sun); a degenerate partial chunk still labels its true span.
  defp week_label(first_day, days_count, tr) do
    last_day = Date.add(first_day, max(days_count - 1, 0))
    week_range_label(first_day, last_day, tr)
  end

  defp week_range_label(day, day, tr), do: "#{I18n.month_name_short(day.month, tr)} #{day.day}"

  defp week_range_label(start, finish, tr) do
    finish_label =
      if start.month == finish.month,
        do: "#{finish.day}",
        else: "#{I18n.month_name_short(finish.month, tr)} #{finish.day}"

    "#{I18n.month_name_short(start.month, tr)} #{start.day}#{finish_label}"
  end

  defp month_label(date, tr) do
    "#{I18n.month_name_short(date.month, tr)} #{date.year}"
  end

  # -- Row position pre-computation --
  # Returns %{
  #   positions: %{event_id => %{top: px, center: px}},
  #   group_header_px: px,
  #   total_height: px
  # }

  defp compute_row_positions(sorted_events, groups, row_px) do
    {positions, total} =
      sorted_events
      |> Enum.with_index()
      |> Enum.reduce({%{}, 0}, fn {event, idx}, {acc, y} ->
        # Add group header height if this event starts a new group
        y = if show_group_header?(groups, event, idx), do: y + @group_header_px, else: y

        entry = %{top: y, center: y + div(row_px, 2)}
        {Map.put(acc, event.id, entry), y + row_px}
      end)

    %{
      positions: positions,
      group_header_px: @group_header_px,
      total_height: total
    }
  end

  # -- Bar geometry (pixel-based) --

  # --- Continuous time→pixel coordinate ---
  #
  # The whole chart positions everything (bars, today marker, connector
  # endpoints, columns) on ONE axis: "fractional days from `range.first`". This
  # is what lets `:hour` (and finer) zoom work without a second coordinate
  # engine — zoom only changes `day_px` and column generation.
  #
  # `frac_days/2` accepts `Date` (midnight), `NaiveDateTime`, or `DateTime`
  # (positioned by WALL-CLOCK time, not elapsed seconds, so a DST day isn't 23
  # or 25 px-hours wide). `x_px/3` rounds to a whole pixel — so for a `Date` at
  # day/week/month zoom it is byte-identical to the old `Date.diff * day_px`,
  # keeping existing behavior; sub-day precision is purely additive.
  # The origin may be a `Date` (the whole-day window — `range.first`) OR a
  # `NaiveDateTime` (a sub-day window, so the axis can start partway through a
  # day). Date-origin + Date-temporal keeps the exact integer-day path, so
  # day/week/month geometry is byte-identical; everything else normalises to
  # naive datetimes and diffs in seconds.
  defp frac_days(nil, _origin), do: 0.0
  defp frac_days(%Date{} = d, %Date{} = origin), do: Date.diff(d, origin) * 1.0

  defp frac_days(temporal, origin),
    do: NaiveDateTime.diff(to_naive_dt(temporal), to_naive_dt(origin), :second) / 86_400

  defp to_naive_dt(%NaiveDateTime{} = n), do: n
  defp to_naive_dt(%Date{} = d), do: NaiveDateTime.new!(d, ~T[00:00:00])
  defp to_naive_dt(%DateTime{} = dt), do: DateTime.to_naive(dt)

  # `@axis_pad_px` shifts the whole coordinate system right so x=0 (the window
  # origin) sits a margin in from the chart's left edge, leaving room for a
  # connector stub that exits/enters a task at the very edge. `content_width`
  # carries the matching 2× growth and spacer columns hold the margin, so bars
  # still exactly cover their time columns — only the absolute %s move.
  defp x_px(temporal, origin, day_px),
    do: @axis_pad_px + round(frac_days(temporal, origin) * day_px)

  # Horizontal coordinates render as PERCENTAGES of the content width, not
  # pixels — so the timeline is responsive: it fills the container when the
  # natural content (`total_days * day_px`, kept as the scroll `min-width`) is
  # narrower, and scrolls when wider, with zero JS. Every px coordinate (shifted
  # right by `@axis_pad_px`) and `content_width` (`total_days * day_px + 2 *
  # @axis_pad_px`) share the same scale, so a single divide converts to a percent
  # and bars, columns, and connectors all stay aligned regardless of the pad.
  defp pct(_px, content_width) when content_width in [0, nil], do: 0.0
  defp pct(px, content_width), do: Float.round(px / content_width * 100, 4)

  # `view` is `{origin, span_days}` — the positioning origin (a `Date` for a
  # whole-day window or a `NaiveDateTime` for a sub-day one) and the visible
  # span in (fractional) days. `{range.first, total_days}` reproduces the old
  # whole-day behaviour exactly.
  defp bar_geometry(event, {origin, span_days} = _view, day_px, min_bar_px) do
    fs = frac_days(event.start, origin)
    fe = frac_days(PhoenixLiveGantt.Task.effective_end(event), origin)
    is_milestone = fe - fs <= 0

    cond do
      out_of_range_frac?(fs, fe, is_milestone, span_days) ->
        %{out_of_range: true}

      is_milestone ->
        %{
          left_px: @axis_pad_px + max(round(fs * day_px), 0),
          width_px: 0,
          milestone: true,
          out_of_range: false
        }

      true ->
        vis_start = max(fs, 0.0)
        vis_end = min(fe, span_days)
        left_px = @axis_pad_px + max(round(vis_start * day_px), 0)
        # Width reflects the TRUE duration; `min_bar_px` (default 0) is an
        # optional floor so a sub-pixel task can stay a visible sliver. With the
        # default the bar is honest — a too-short-to-see task is a hairline until
        # zoomed in — and connectors attach to this same (un-inflated) edge.
        width_px = max(round((vis_end - vis_start) * day_px), min_bar_px)
        %{left_px: left_px, width_px: width_px, milestone: false, out_of_range: false}
    end
  end

  # Overlap test in fractional-day space (relative to range.first, so range
  # spans [0, total_days)). A milestone is its single instant; a ranged event
  # is the half-open `[fs, fe)`.
  defp out_of_range_frac?(fs, _fe, true, total_days), do: fs < 0 or fs >= total_days
  defp out_of_range_frac?(fs, fe, false, total_days), do: fe <= 0 or fs >= total_days

  # -- Connector path (orthogonal routing) --
  #
  # Supports the four standard Gantt dependency types:
  #
  #   :fs — finish-to-start  (default) — A must finish before B can start
  #   :ss — start-to-start              — A must start   before B can start
  #   :ff — finish-to-finish            — A must finish  before B can finish
  #   :sf — start-to-finish             — A must start   before B can finish
  #
  # Every arrow is the same three-segment shape `M x1 y1 H mid V y2 H x2`.
  # The type only changes where x1, x2, and mid_x sit and which side of
  # each bar the stem exits / enters, which flips the arrowhead direction
  # via SVG's `orient="auto"`.
  #
  # A "backward" / invalid schedule (the later task is actually scheduled
  # earlier than the constraint allows) is detected uniformly by
  # `conflict?/3`. For :fs we draw a five-segment detour because the
  # forward stems point INTO each other and can't resolve; for :ss/:ff/:sf
  # the normal three-segment shape stays coherent because both stems exit
  # the same side of their respective bars.
  #
  # Connectors may also carry:
  #
  #   critical: true   — rendered with primary color, thicker stroke,
  #                      and the `lg-arrow-critical` marker
  #   label: "2d lag"  — rendered as <text> with a base-100 halo near
  #                      the path midpoint (forward) or detour leg
  #                      (backward) for lag/lead annotations

  # Normalize a consumer-supplied connector into a struct with defaults.
  #
  # Every field beyond from/to is optional. Styling overrides
  # (color_class/stroke_width/opacity/dasharray) default to nil and are
  # resolved against the component-level defaults in `resolve_style/3`.
  # Routing overrides (exit_stem/entry_stem/detour_side/bar_clearance/
  # avoid_collisions/shape) default to nil (or :auto for enums) and are
  # checked inline in the path builders.
  defp normalize_connector(conn) do
    %{
      from: conn.from,
      to: conn.to,
      type: Map.get(conn, :type, :fs),
      critical: Map.get(conn, :critical, false),
      label: Map.get(conn, :label),
      label_orientation: Map.get(conn, :label_orientation, :horizontal),
      color_class: Map.get(conn, :color_class),
      stroke_width: Map.get(conn, :stroke_width),
      opacity: Map.get(conn, :opacity),
      dasharray: Map.get(conn, :dasharray),
      exit_stem: Map.get(conn, :exit_stem),
      entry_stem: Map.get(conn, :entry_stem),
      detour_side: Map.get(conn, :detour_side, :auto),
      bar_clearance: Map.get(conn, :bar_clearance),
      avoid_collisions: Map.get(conn, :avoid_collisions),
      shape: Map.get(conn, :shape, :auto)
    }
  end

  defp compute_connector_paths(normalized_connectors, ctx) do
    normalized_connectors
    |> Enum.flat_map(fn conn ->
      case connector_path(conn, ctx) do
        nil -> []
        path -> [path]
      end
    end)
    |> consolidate_piercing_trunks(ctx)
    |> Enum.map(&finalize_arrowhead/1)
  end

  # Compute each connector's arrowhead AFTER all path rewrites
  # (`consolidate_piercing_trunks` can replace a forward path's `d` with a
  # multi-hop jog that ENDS AT A DIFFERENT POINT than the original). The head
  # must sit on the shaft's ACTUAL end — the old `marker-end` rode the path so
  # this was automatic; the separate overlay layer must re-derive it from the
  # final `d`. We read the last point + final-segment direction generically, so
  # it's correct for the 3-seg forward, 5-seg detour, and N-seg jog alike.
  defp finalize_arrowhead(p) do
    {tip_x, tip_y, dir} = arrowhead_from_d(p.d)

    variant =
      cond do
        p.invalid -> :invalid
        p.critical -> :critical
        true -> :normal
      end

    %{p | arrow: arrowhead_geometry(tip_x, tip_y, dir, variant, p.target_milestone)}
  end

  # Post-pass: for every FORWARD path whose trunk pierces an unrelated
  # bar, try to repair it without leaving the chart:
  #
  #   1. Single-column shift to a sibling's lane that's clean for the
  #      full y-span (turns two parallel arrows into one shared rail).
  #   2. Two-column "jog" — the trunk drops in one column for the
  #      upper half of the y-span, hops horizontally through a
  #      row-gap, then continues in a second column. This is what the
  #      user means by "join other arrows going in the same direction
  #      with a small detour" — each column can be a sibling's lane.
  #
  # Both candidates respect the connector type's directional valid
  # range (east of both endpoints for FF/SF, between for FS, west of
  # both for SS). Falls back to leaving the piercing alone if neither
  # repair is possible.
  defp consolidate_piercing_trunks(paths, ctx) do
    forwards =
      paths
      |> Enum.with_index()
      |> Enum.flat_map(fn {p, i} ->
        case PathFormat.parse(p.d) do
          %{kind: :forward, x1: x1, y1: y1, mid: mid, y2: y2, arrow_stop: stop} ->
            [{i, %{path: p, x1: x1, y1: y1, mid: mid, y2: y2, arrow_stop: stop}}]

          _ ->
            []
        end
      end)

    candidate_trunks = forwards |> Enum.map(fn {_, f} -> f.mid end) |> Enum.uniq()
    row_px = Map.get(ctx, :row_px, 40)

    rewrites =
      forwards
      |> Enum.flat_map(fn {idx, f} ->
        exclude = exclude_ids_for(paths, idx)
        bars_in_span = bars_crossing_span(ctx.bars, f.y1, f.y2, exclude)

        if trunk_collides?(f.mid, bars_in_span) do
          {min_x, max_x} = valid_range_for_type(f, paths, idx)
          in_range = fn x -> x >= min_x and x <= max_x end

          # Pass 1: full-span clean column (single mid_x for the whole trunk).
          full_clean =
            candidate_trunks
            |> Enum.reject(&(&1 == f.mid))
            |> Enum.filter(fn x -> in_range.(x) and not trunk_collides?(x, bars_in_span) end)
            |> Enum.min_by(fn x -> abs(x - f.mid) end, fn -> nil end)

          cond do
            full_clean ->
              [{idx, rewrite_forward(f, full_clean)}]

            true ->
              # Pass 2: greedy N-segment walker — start at preferred
              # column, walk down the y-span row by row, and whenever
              # the current column hits a bar, hop horizontally
              # through the row gap above it onto another clean
              # column. Can chain many switches.
              case find_multi_segment_jog(f, candidate_trunks, ctx, exclude, in_range, row_px) do
                nil -> []
                segments -> [{idx, rewrite_forward_segments(f, segments)}]
              end
          end
        else
          []
        end
      end)
      |> Map.new()

    paths
    |> Enum.with_index()
    |> Enum.map(fn {p, i} -> Map.get(rewrites, i, p) end)
  end

  # Greedy multi-segment walker. Walks down from y1 to y2 in column
  # `current_x`, and whenever the current column hits a bar, hops
  # horizontally to another clean column at the row-gap ABOVE the
  # blocking bar. Returns a list `[{x_i, switch_y_i}, ...]` where
  # x_i is the column used until switch_y_i. Last element's
  # switch_y is y2.
  defp find_multi_segment_jog(f, candidate_trunks, ctx, exclude, in_range_fn, row_px) do
    valid_xs = candidate_trunks |> Enum.filter(in_range_fn) |> Enum.uniq() |> Enum.sort()
    {y_top, y_bot} = if f.y1 < f.y2, do: {f.y1, f.y2}, else: {f.y2, f.y1}

    walk_segments(f.mid, y_top, y_bot, valid_xs, ctx, exclude, row_px, [], 0)
  end

  # `max_hops` caps how many column changes we'll attempt — pathological
  # layouts could otherwise spin forever, and visually more than 3-4
  # zigzags looks worse than just piercing.
  @max_jog_hops 5

  defp walk_segments(_current_x, y_at, y_bot, _valid_xs, _ctx, _exclude, _row_px, _acc, hops)
       when hops > @max_jog_hops and y_at < y_bot do
    nil
  end

  defp walk_segments(current_x, y_at, y_bot, valid_xs, ctx, exclude, row_px, acc, hops) do
    bars_below = bars_crossing_span(ctx.bars, y_at, y_bot, exclude)
    first_blocker = first_blocking_bar(current_x, bars_below)

    if is_nil(first_blocker) do
      # Clean to the end — emit final segment and stop.
      Enum.reverse([{current_x, y_bot} | acc])
    else
      # Switch column at the row-gap above the blocker.
      switch_y = first_blocker.y_top - 2

      if switch_y <= y_at do
        # No room to switch — give up.
        nil
      else
        remaining_bars = bars_crossing_span(ctx.bars, switch_y, y_bot, exclude)

        # Need a column that is clean from switch_y..y_bot AND
        # has a clear horizontal jog at switch_y from current_x.
        next_x =
          valid_xs
          |> Enum.reject(&(&1 == current_x))
          |> Enum.filter(fn x ->
            not trunk_collides?(x, remaining_bars) and
              jog_clear?(current_x, x, switch_y, ctx.bars, exclude)
          end)
          # Prefer the column closest to current_x so the jog is small.
          |> Enum.min_by(fn x -> abs(x - current_x) end, fn -> nil end)

        if next_x do
          walk_segments(
            next_x,
            switch_y,
            y_bot,
            valid_xs,
            ctx,
            exclude,
            row_px,
            [{current_x, switch_y} | acc],
            hops + 1
          )
        else
          # No clean column for the remainder; settle for partial
          # routing and let the original piercing finish the path.
          # Currently we just bail — but a deeper search could try
          # different switch_ys here.
          nil
        end
      end
    end
  end

  # First bar (in y order) at column `x` that the trunk would pierce.
  defp first_blocking_bar(x, bars) do
    bars
    |> Enum.filter(fn b -> b.x_left < x and x < b.x_right end)
    |> Enum.min_by(& &1.y_top, fn -> nil end)
  end

  # True iff the horizontal jog at y=`switch_y` from x_a..x_b doesn't
  # pass through any bar's box (with a 1px tolerance).
  defp jog_clear?(x_a, x_b, switch_y, bars, exclude) do
    {x_lo, x_hi} = if x_a < x_b, do: {x_a, x_b}, else: {x_b, x_a}

    not Enum.any?(bars, fn b ->
      not MapSet.member?(exclude, b.event_id) and
        b.x_left < x_hi and b.x_right > x_lo and
        b.y_top < switch_y + 1 and b.y_bottom > switch_y - 1
    end)
  end

  # Re-emit the path as an N-segment polyline:
  #   M x1 y1 H seg1_x V seg1_y H seg2_x V seg2_y ... H stop V y2 H stop
  # Built directly here since PathFormat only owns 3- and 5-segment
  # forms; this is a multi-hop shape only used by the consolidator.
  defp rewrite_forward_segments(%{path: p, x1: x1, y1: y1, y2: _y2, arrow_stop: stop}, segments) do
    # Each segment is {column_x, end_y_of_that_column}. The last
    # segment's end_y is y2.
    middle =
      Enum.map_join(segments, " ", fn {x, y} -> "H #{x} V #{y}" end)

    final_x = segments |> List.last() |> elem(0)
    d = "M #{x1} #{y1} #{middle} H #{stop}"

    %{p | d: d, label_x: final_x}
  end

  # `from_id` / `to_id` for an already-emitted path live on the path
  # map; use them to build the same exclude-set the original routing
  # used (source + target).
  defp exclude_ids_for(paths, idx) do
    case Enum.at(paths, idx) do
      %{from_id: from, to_id: to} -> MapSet.new([from, to])
      _ -> MapSet.new()
    end
  end

  # Look up the connector type on the original path so we can pick the
  # valid x-range for the sibling search. The type determines which
  # side of each bar the arrow exits/enters, so a sibling x WEST of
  # an FF arrow's east-east endpoints would force a 180° loop.
  defp valid_range_for_type(f, paths, idx) do
    path = Enum.at(paths, idx)
    type = Map.get(path, :type, :fs)
    tgt_ms = Map.get(path, :target_milestone, false)
    {src_exit, tgt_entry} = exit_entry_for(type)
    mid_valid_range(f.x1, f.arrow_stop, src_exit, tgt_entry, tgt_ms)
  end

  defp exit_entry_for(:fs), do: {:east, :west}
  defp exit_entry_for(:ss), do: {:west, :west}
  defp exit_entry_for(:ff), do: {:east, :east}
  defp exit_entry_for(:sf), do: {:west, :east}
  defp exit_entry_for(_), do: {:east, :west}

  # Rebuild a forward path map with a new mid x. The `d` string and
  # any cached trunk label position get re-derived; everything else
  # carries through.
  defp rewrite_forward(%{path: p} = f, new_mid) do
    %{p | d: PathFormat.forward(f.x1, f.y1, new_mid, f.y2, f.arrow_stop), label_x: new_mid}
  end

  defp connector_path(conn, ctx) do
    from_event = Map.get(ctx.events_by_id, conn.from)
    to_event = Map.get(ctx.events_by_id, conn.to)
    from_pos = Map.get(ctx.row_positions.positions, conn.from)
    to_pos = Map.get(ctx.row_positions.positions, conn.to)

    if from_event && to_event && from_pos && to_pos do
      conn_key = {conn.from, conn.to, conn.type}
      {src_lane, src_bus_size} = Map.get(ctx.bus_lanes.source, conn_key, {0, 1})
      {tgt_lane, tgt_bus_size} = Map.get(ctx.bus_lanes.target, conn_key, {0, 1})

      geom = %{
        source_fanout: Map.get(ctx.outgoing_count, {conn.from, conn.type}, 1),
        target_fanin: Map.get(ctx.incoming_count, {conn.to, conn.type}, 1),
        lane: Map.get(ctx.backward_lanes, conn_key, 0),
        source_lane: src_lane,
        source_bus_size: src_bus_size,
        target_lane: tgt_lane,
        target_bus_size: tgt_bus_size,
        from_event: from_event,
        to_event: to_event,
        from_pos: from_pos,
        to_pos: to_pos
      }

      build_path(conn, geom, ctx)
    end
  end

  # Counts are keyed by {event_id, type} so a source with mixed-type
  # outgoing arrows doesn't produce false bus collapsing across types —
  # the :fs and :ss arrows from the same task leave different edges of
  # the bar, so they should not share trunks.
  defp count_connector_endpoints(connectors) do
    Enum.reduce(connectors, {%{}, %{}}, fn conn, {out_acc, in_acc} ->
      {
        Map.update(out_acc, {conn.from, conn.type}, 1, &(&1 + 1)),
        Map.update(in_acc, {conn.to, conn.type}, 1, &(&1 + 1))
      }
    end)
  end

  # Per-{event_id, side} tally of arrows by attach class.
  # Keys: {event_id, :east | :west}.
  # Values: %{out_up: int, in_above: int, in_below: int, out_down: int}
  # The "above/below" / "up/down" axis is determined by comparing the row
  # positions of the two endpoints. Used by smart-mode `attach_y/5` to
  # decide which of 4 designated y positions to use, and to collapse to
  # bar center when only one class is present on a side.
  defp count_per_side(connectors, row_positions) do
    Enum.reduce(connectors, %{}, fn conn, acc ->
      src_pos = Map.get(row_positions.positions, conn.from)
      tgt_pos = Map.get(row_positions.positions, conn.to)

      if src_pos && tgt_pos do
        src_side = source_side_for(conn.type)
        tgt_side = target_side_for(conn.type)

        # Same-row connectors are degenerate. Treat them as :down/:above by
        # convention (`>=` and `<=`) so the classification is deterministic.
        src_class = if tgt_pos.top >= src_pos.top, do: :out_down, else: :out_up
        tgt_class = if src_pos.top <= tgt_pos.top, do: :in_above, else: :in_below

        acc
        |> bump_attach({conn.from, src_side}, src_class)
        |> bump_attach({conn.to, tgt_side}, tgt_class)
      else
        acc
      end
    end)
  end

  @empty_attach_tally %{out_up: 0, in_above: 0, in_below: 0, out_down: 0}

  defp bump_attach(acc, key, attach_class) do
    Map.update(
      acc,
      key,
      Map.put(@empty_attach_tally, attach_class, 1),
      &Map.update(&1, attach_class, 1, fn n -> n + 1 end)
    )
  end

  # Classify a connector end. Mirrors `count_per_side/2`'s logic so the
  # attach class computed here lines up with the tallied class.
  defp source_attach_class(from_pos, to_pos) do
    if to_pos.top >= from_pos.top, do: :out_down, else: :out_up
  end

  defp target_attach_class(from_pos, to_pos) do
    if from_pos.top <= to_pos.top, do: :in_above, else: :in_below
  end

  # Static type → exit/entry side mapping, mirrors `endpoints_for/5`.
  defp source_side_for(:fs), do: :east
  defp source_side_for(:ff), do: :east
  defp source_side_for(:ss), do: :west
  defp source_side_for(:sf), do: :west

  defp target_side_for(:fs), do: :west
  defp target_side_for(:ss), do: :west
  defp target_side_for(:ff), do: :east
  defp target_side_for(:sf), do: :east

  # Per-bar map `%{id => %{left: bool, right: bool}}` of which side(s) a connector
  # attaches to — a source occupies its exit side, a target its entry side
  # (per the type→side mapping above). Used so an `:auto` track label can dodge to
  # the side with no arrow. (`:east` = the bar's right/after track, `:west` = its
  # left/before track.)
  defp compute_label_arrow_sides(connectors) do
    Enum.reduce(connectors, %{}, fn c, acc ->
      acc
      |> mark_arrow_side(c.from, side_lr(source_side_for(c.type)))
      |> mark_arrow_side(c.to, side_lr(target_side_for(c.type)))
    end)
  end

  defp mark_arrow_side(acc, id, lr) do
    sides = Map.get(acc, id, %{left: false, right: false})
    Map.put(acc, id, Map.put(sides, lr, true))
  end

  defp side_lr(:east), do: :right
  defp side_lr(:west), do: :left

  # Vertical attachment point on a bar's edge. Falls back to row center in
  # three cases:
  # Compute the y where an arrow attaches to a bar's edge. Dispatches on
  # the configured `bus_attach_mode` (per-task `extra.bus_attach_mode`
  # overrides component-level setting). Always returns row center for
  # milestones (16px diamonds have no meaningful split point).
  defp attach_y(pos, event, side, attach_class, ctx) do
    if milestone?(event) do
      pos.center
    else
      mode = resolve_attach_mode(event, ctx)
      do_attach_y(mode, pos, event, side, attach_class, ctx)
    end
  end

  defp resolve_attach_mode(event, ctx) do
    case event.extra do
      %{bus_attach_mode: m} when m in [:smart, :type_zoned, :center] -> m
      _ -> ctx.bus_attach_mode
    end
  end

  # `:center` — never split; legacy behaviour.
  defp do_attach_y(:center, pos, _event, _side, _attach_class, _ctx), do: pos.center

  # `:smart` — two positions per side, picked by aggregate direction:
  #   1. Count this side's outgoing arrows by where the OTHER end sits
  #      (out_up vs out_down). The majority decides outgoing's region:
  #      most going down → outgoing at bar bottom; most going up → top.
  #      Ties pick bottom (matches the typical Gantt direction).
  #   2. Incoming takes the OPPOSITE region. This auto-resolves the
  #      ambiguous case where outgoing and incoming would otherwise want
  #      the same region (e.g., out_up + in_above on the same side).
  #   3. Side has only one direction (only out OR only in) → collapse to
  #      row center, since there's no other group to disambiguate from.
  # Both positions use `bus_attach_inner_pct` (default 40 → 40%/60% split).
  defp do_attach_y(:smart, pos, event, side, attach_class, ctx) do
    tally = Map.get(ctx.side_tally, {event.id, side}, @empty_attach_tally)
    out_count = tally.out_up + tally.out_down
    in_count = tally.in_above + tally.in_below

    cond do
      in_count == 0 or out_count == 0 ->
        pos.center

      true ->
        outgoing_at_bottom? = tally.out_down >= tally.out_up

        direction =
          if attach_class in [:out_up, :out_down], do: :outgoing, else: :incoming

        at_bottom? =
          case direction do
            :outgoing -> outgoing_at_bottom?
            :incoming -> not outgoing_at_bottom?
          end

        if at_bottom?,
          do: bar_bottom_attach(pos, ctx),
          else: bar_top_attach(pos, ctx)
    end
  end

  # `:type_zoned` — backwards-compatible behaviour: outgoing rides the
  # upper region of the bar, incoming the lower, regardless of the
  # OTHER end's row position. Uses `bus_split_offset_pct`.
  defp do_attach_y(:type_zoned, pos, event, side, attach_class, ctx) do
    tally = Map.get(ctx.side_tally, {event.id, side}, @empty_attach_tally)
    out_count = tally.out_up + tally.out_down
    in_count = tally.in_above + tally.in_below

    if in_count > 0 and out_count > 0 do
      direction = if attach_class in [:out_up, :out_down], do: :outgoing, else: :incoming
      split_attach_y(pos, direction, ctx.row_px, ctx.bus_split_offset_pct)
    else
      pos.center
    end
  end

  # Bar's upper-region attach point (used by smart mode when this side's
  # outgoing/incoming should sit toward the top of the bar).
  # Both helpers use `bus_attach_inner_pct` so smart mode has exactly two
  # positions per side (default 40%/60%).
  defp bar_top_attach(pos, ctx) do
    bar_top = pos.top + 4
    bar_height = max(ctx.row_px - 8, 8)
    bar_top + div(bar_height * ctx.bus_attach_inner_pct, 100)
  end

  defp bar_bottom_attach(pos, ctx) do
    bar_top = pos.top + 4
    bar_height = max(ctx.row_px - 8, 8)
    bar_top + bar_height - div(bar_height * ctx.bus_attach_inner_pct, 100)
  end

  # Type-zoned split. Bar inset: top-1 bottom-1 (Tailwind) = 4px each →
  # bar height = row_px - 8. `offset_pct` is the offset from the bar's
  # top edge for outgoing; incoming mirrors from the bottom.
  defp split_attach_y(pos, :outgoing, row_px, offset_pct) do
    bar_top = pos.top + 4
    bar_height = max(row_px - 8, 8)
    bar_top + div(bar_height * offset_pct, 100)
  end

  defp split_attach_y(pos, :incoming, row_px, offset_pct) do
    bar_top = pos.top + 4
    bar_height = max(row_px - 8, 8)
    bar_top + bar_height - div(bar_height * offset_pct, 100)
  end

  defp build_path(conn, geom, ctx) do
    %{
      x1: x1,
      arrow_stop: arrow_stop,
      source_exit: source_exit,
      target_entry: target_entry,
      backward: backward?
    } =
      endpoints_for(
        conn.type,
        geom.from_event,
        geom.to_event,
        ctx.view,
        ctx.day_px,
        ctx.min_bar_px
      )

    # Per-end attachment y. Three cases:
    # 1. Stagger active AND multiple arrows on this bus → distribute
    #    lanes evenly across the bar's flat region (excluding rounded
    #    corners), so each arrow emerges at a unique point INSIDE the
    #    bar's visible area (no mid-air emergence at a corner).
    # 2. Otherwise → use the smart-mode attach (out_down/in_above/etc.)
    #    or `:type_zoned` / `:center` depending on `bus_attach_mode`.
    # Milestones always use row center regardless.
    src_class = source_attach_class(geom.from_pos, geom.to_pos)
    tgt_class = target_attach_class(geom.from_pos, geom.to_pos)
    y1 = compute_attach_y(:source, geom, source_exit, src_class, ctx)
    y2 = compute_attach_y(:target, geom, target_entry, tgt_class, ctx)

    label_w = estimate_label_width(conn.label)
    style = resolve_style(conn, backward?, ctx.style_defaults)

    route = build_route(conn, source_exit, target_entry, label_w, geom, ctx)

    # Routing ctx folds per-connector overrides (avoid_collisions,
    # line margin) onto the ambient ctx, so downstream helpers read
    # the effective value without threading them as extra args.
    routing_ctx =
      ctx
      |> Map.put(:avoid_collisions, route.avoid_collisions)
      |> Map.put(:line_margin, route.bar_clearance)
      |> Map.put(:target_row_top, geom.to_pos.top)

    # Decide forward vs detour. Heuristic + collision-fallback:
    #   1. Tight gap or label-doesn't-fit (existing `use_detour?`)
    #   2. (NEW) For :fs forward, check if the trunk's preferred x can
    #      actually avoid all intermediate bars. If not, force detour —
    #      its routing-via-row-border avoids the bars entirely.
    tgt_ms = milestone?(geom.to_event)

    fs_detour? =
      use_detour?(conn, x1, arrow_stop, backward?, label_w, tgt_ms) or
        forward_path_unfeasible?(conn, x1, y1, arrow_stop, y2, geom, route, routing_ctx)

    {d, label_x, label_y, label_transform} =
      if fs_detour? do
        build_fs_detour_path(x1, y1, arrow_stop, y2, geom, routing_ctx, route)
      else
        build_forward_path(x1, y1, arrow_stop, y2, geom, routing_ctx, route)
      end

    %{
      d: d,
      from_id: geom.from_event.id,
      to_id: geom.to_event.id,
      type: conn.type,
      critical: conn.critical,
      invalid: backward?,
      label: conn.label,
      label_x: label_x,
      label_y: label_y,
      label_width: label_w,
      label_transform: label_transform,
      color_class: style.color_class,
      # The arrowHEAD shares the line's hue but is drawn SOLID — its alpha
      # modifier (e.g. the default `text-base-content/50`) is stripped so the
      # head fully covers the shaft end instead of letting the line show through
      # a half-transparent triangle. See `opaque_class/1`.
      head_color_class: opaque_class(style.color_class),
      stroke_width: style.stroke_width,
      opacity: style.opacity,
      dasharray: style.dasharray,
      # Whether the TARGET is a milestone diamond — `finalize_arrowhead/1` uses
      # it to nudge the head off the diamond's centre to its edge.
      target_milestone: milestone?(geom.to_event),
      # Placeholder — recomputed from the FINAL `d` by `finalize_arrowhead/1`
      # after path rewrites. Present here so the map has the key to update.
      arrow: nil
    }
  end

  # The arrowhead is drawn in a SEPARATE, non-stretched overlay layer (see the
  # render) — positioned by % (so its tip tracks the bar-aligned path end as the
  # chart fills/scrolls) but sized in fixed px (so it stays a crisp triangle
  # instead of stretching with the horizontal fill factor). A stretched line is
  # still a correct line, so the SHAFT can live in the %-stretched SVG; a
  # stretched triangle is not an arrowhead, so the HEAD can't.
  #
  # Reduce a (post-rewrite) M/H/V path `d` to its arrowhead anchor: the last
  # point and the direction of the final segment. Every shape family — the
  # 3-seg forward, 5-seg detour, and the consolidator's N-seg jog — ends in a
  # horizontal "H stop", so the head points east (→) or west (←); anything else
  # collapses to east. Reading the actual final `d` (rather than the pre-rewrite
  # endpoints) keeps the head on the shaft's true end even when
  # `consolidate_piercing_trunks` re-routes it.
  defp arrowhead_from_d(d) do
    %{x: tip_x, y: tip_y, dir: dir} = PathFormat.terminal(d)
    {tip_x, tip_y, if(dir == :west, do: :west, else: :east)}
  end

  # Precompute everything the overlay needs: the tip anchor (tip_x in px →
  # rendered as % of content width; tip_y in px, vertically un-stretched), the
  # fixed px triangle `d`, and the px nudge that lands the triangle's tip on the
  # anchor. Critical arrows are a touch larger, matching the old marker sizing.
  # Half-diagonal of the default `w-4` (16px) milestone diamond, plus a hair —
  # how far OUTSIDE the diamond's centre its near point sits. The shaft attaches
  # at the centre (covered by the z-40 diamond), but the fixed-px arrowhead is
  # nudged out to here so it reads as pointing AT the diamond, not buried in it.
  @milestone_edge_px 12

  defp arrowhead_geometry(tip_x, tip_y, dir, variant, target_milestone?) do
    size = if variant == :critical, do: 10, else: 8
    half = div(size, 2)

    # Triangle drawn in a 0..size box; tip on the side it points toward, then the
    # svg is offset so that tip coincides with the (tip_x, tip_y) anchor.
    {d, base_off_x} =
      case dir do
        :east -> {"M 0 0 L #{size} #{half} L 0 #{size} z", -size}
        :west -> {"M #{size} 0 L 0 #{half} L #{size} #{size} z", 0}
      end

    # The container stays anchored on the shaft end (`tip_x`), but for a milestone
    # target we shift the drawn triangle OUT to the diamond's edge via the svg
    # offset (a fixed px, so it clears the fixed-px diamond at any fill). The
    # anchor staying on the shaft end keeps the head-meets-shaft invariant valid;
    # only the visible triangle moves.
    nudge =
      cond do
        not target_milestone? -> 0
        dir == :east -> -@milestone_edge_px
        true -> @milestone_edge_px
      end

    %{
      tip_x: tip_x,
      tip_y: tip_y,
      size: size,
      d: d,
      off_x: base_off_x + nudge,
      off_y: -half,
      variant_class: arrowhead_variant_class(variant)
    }
  end

  # Keep the legacy `lg-arrow{,-invalid,-critical}` tokens so styling hooks and
  # tests that keyed on the old marker ids still match.
  defp arrowhead_variant_class(:invalid), do: "lg-arrow-invalid"
  defp arrowhead_variant_class(:critical), do: "lg-arrow-critical"
  defp arrowhead_variant_class(:normal), do: "lg-arrow"

  # Strip Tailwind/daisyUI opacity modifiers from a color class so the arrowhead
  # renders SOLID, even when the line is deliberately subtle. Strips PER TOKEN, so
  # a multi-token / variant override de-alphas every part — e.g.
  # `"text-primary/30 dark:text-primary/50"` → `"text-primary dark:text-primary"`
  # (a solid head in both light and dark) and `"text-base-content/50 font-bold"` →
  # `"text-base-content font-bold"`. Handles the slash-percentage forms
  # (`/50`, `/12.5`) and arbitrary values (`/[0.4]`). Intended for the connector
  # COLOR class; a token with no modifier passes through, so opaque colors are
  # untouched. (A token's trailing `/n` is treated as opacity — don't pass layout
  # fractions like `w-1/2` as a connector color.)
  defp opaque_class(nil), do: nil

  defp opaque_class(class) when is_binary(class) do
    class
    |> String.split()
    |> Enum.map_join(" ", &String.replace(&1, ~r{/(?:\d+(?:\.\d+)?|\[[^\]]*\])$}, ""))
  end

  # Bundle source/target endpoint info with per-connector routing
  # overrides (or fallbacks to ctx defaults) so individual builders see
  # a flat map rather than reaching into conn/ctx for every knob.
  defp build_route(conn, source_exit, target_entry, label_w, geom, ctx) do
    %{
      exclude_ids: MapSet.new([geom.from_event.id, geom.to_event.id]),
      source_exit: source_exit,
      target_entry: target_entry,
      label_width: label_w,
      label_orientation: conn.label_orientation,
      exit_stem: conn.exit_stem || ctx.elbow_px,
      entry_stem: conn.entry_stem || ctx.elbow_px,
      detour_side: conn.detour_side,
      bar_clearance: conn.bar_clearance || ctx.bar_clearance_px,
      avoid_collisions: resolve_bool(conn.avoid_collisions, ctx.avoid_collisions)
    }
  end

  # Decide whether to render as a 5-segment detour or a 3-segment
  # direct. Per-connector `shape` overrides the auto heuristic:
  #   :direct — never detour (except backward, which has no coherent
  #             3-seg shape and must detour regardless)
  #   :detour — always detour, even with a wide gap
  #   :auto   — detour only when the gap is too tight for clean stems
  #             or the label doesn't fit between the bars
  defp use_detour?(%{type: :fs, shape: :direct}, _x1, _arrow_stop, backward?, _label_w, _tgt_ms),
    do: backward?

  defp use_detour?(%{type: :fs, shape: :detour}, _x1, _arrow_stop, _backward?, _label_w, _tgt_ms),
    do: true

  defp use_detour?(%{type: :fs}, x1, arrow_stop, backward?, label_w, tgt_ms),
    do: backward? or forward_fs_needs_detour?(x1, arrow_stop, label_w, tgt_ms)

  defp use_detour?(_conn, _x1, _arrow_stop, _backward?, _label_w, _tgt_ms), do: false

  # True when the forward 3-seg path can't place its trunk x without
  # piercing an intermediate bar. Only applies to :fs (other types have
  # different shape families and either don't have intermediate bars in
  # their trunk's y span, or accept piercing as a known limit). When
  # `avoid_collisions` is off or the connector isn't :fs, returns false
  # (let the existing logic run).
  #
  # Used by `build_path/3` to fall back from forward to detour when the
  # forward path is geometrically blocked. The detour path routes via the
  # row border (just past source's row top/bottom) which slips through
  # the gap between bars regardless of x.
  defp forward_path_unfeasible?(%{type: :fs}, x1, y1, arrow_stop, y2, geom, route, ctx) do
    if not Map.get(ctx, :avoid_collisions, true) do
      false
    else
      elbow = Map.get(route, :exit_stem, @elbow_px)
      tgt_ms = milestone?(geom.to_event)

      base_mid =
        choose_mid_x(
          x1,
          arrow_stop,
          route.source_exit,
          route.target_entry,
          geom.source_fanout,
          geom.target_fanin,
          Map.get(route, :label_width, 0),
          elbow,
          tgt_ms
        )

      {min_x, max_x} =
        mid_valid_range(x1, arrow_stop, route.source_exit, route.target_entry, tgt_ms)

      preferred_mid =
        (base_mid + forward_stagger_offset(geom, route, ctx)) |> max(min_x) |> min(max_x)

      bars_in_span = bars_crossing_span(ctx.bars, y1, y2, route.exclude_ids)

      cond do
        bars_in_span == [] ->
          false

        # Preferred trunk already keeps real clearance — forward is fine.
        trunk_clearance(preferred_mid, bars_in_span) >= @trunk_min_clearance_px ->
          false

        # A trunk with ≥floor clearance is reachable in range, so
        # `maybe_shift_trunk` will move the trunk there — forward still works.
        # This MUST mirror the placer's own reachability test. The old check
        # used `not trunk_collides?`, which counts a bar-to-bar JUNCTION (an x
        # exactly between two touching bars) as clean despite ZERO real
        # clearance — so a tight staircase of consecutive bars looked feasible
        # while the placer could only pierce. Reusing `clear_trunk_x` (the same
        # helper the placer uses) makes the decision and the placement agree.
        clear_trunk_x(preferred_mid, bars_in_span, @trunk_min_clearance_px, min_x, max_x) ->
          false

        # No clearance reachable and the preferred would pierce → route around
        # via the detour (which travels the inter-row borders) instead.
        true ->
          true
      end
    end
  end

  defp forward_path_unfeasible?(_conn, _x1, _y1, _arrow_stop, _y2, _geom, _route, _ctx),
    do: false

  # Merge per-connector style overrides (nil = inherit) onto the
  # category defaults (normal / critical / invalid).
  defp resolve_style(conn, invalid?, defaults) do
    category =
      cond do
        invalid? -> defaults.invalid
        conn.critical -> defaults.critical
        true -> defaults.normal
      end

    %{
      color_class: conn.color_class || category.color_class,
      stroke_width: conn.stroke_width || category.stroke_width,
      opacity: conn.opacity || category.opacity,
      dasharray: conn.dasharray || category.dasharray
    }
  end

  defp resolve_bool(nil, default), do: default
  defp resolve_bool(value, _default), do: value

  defp estimate_label_width(nil), do: 0
  defp estimate_label_width(""), do: 0

  defp estimate_label_width(label) when is_binary(label),
    do: String.length(label) * @label_char_px

  # Gap (px) between a bar and an `:outside` label.
  @track_label_gap_px 6

  # Rough px width of one bar-label character. The bar label is `text-xs` (~12px),
  # heavier than the connector label (`text-[0.6rem]`, `@label_char_px`), so it
  # gets its own estimate — used for the `:outside` after→before flip and the
  # `:fit` container-query breakpoint. Approximate by design (no font metrics).
  @bar_label_char_px 7

  defp bar_label_est(nil), do: 0
  defp bar_label_est(title) when is_binary(title), do: String.length(title) * @bar_label_char_px

  # Position string for a label in the empty TRACK beside the bar — `:right` =
  # just past the bar's end, `:left` = just before its start. The ANCHOR is a
  # content-% position (a true bar edge, or a milestone's center) so it tracks the
  # event as the fill stretches the timeline; the GAP is FIXED px so it stays
  # constant at every zoom. `:auto` picks the side via `auto_after?/3` — dodging
  # the bar's connector arrows when it can. Shared by `:outside` / `:watermark`.
  defp track_position(bar, cw, est, side, arrows) do
    {anchor_after_px, anchor_before_px, pad} =
      if bar.milestone do
        center = bar.left_px
        {center, center, @milestone_edge_px + @track_label_gap_px}
      else
        {bar.left_px + bar.width_px, bar.left_px, @track_label_gap_px}
      end

    after_fits = anchor_after_px + pad + est <= cw - @axis_pad_px
    before_fits = anchor_before_px - pad - est >= @axis_pad_px

    after? =
      case side do
        :left -> false
        :right -> true
        _ -> auto_after?(after_fits, before_fits, arrows)
      end

    if after?,
      do: "left: #{pct(anchor_after_px, cw)}%; padding-left: #{pad}px; text-align: left;",
      else:
        "right: #{pct(cw - anchor_before_px, cw)}%; padding-right: #{pad}px; text-align: right;"
  end

  # `:auto` side choice. Prefer the track with NO arrow (so the label doesn't
  # overlap a dependency line), then fall back to whichever side the label
  # actually fits in. `arrows` is `%{left:, right:}` for this bar — all-false when
  # connectors are hidden / the bar has none, so this collapses to the plain
  # fit-flip (after unless it would overflow the right edge, then before).
  defp auto_after?(after_fits, before_fits, arrows) do
    right_arrow = Map.get(arrows, :right, false)
    left_arrow = Map.get(arrows, :left, false)

    cond do
      after_fits and not right_arrow -> true
      before_fits and not left_arrow -> false
      after_fits -> true
      before_fits -> false
      true -> true
    end
  end

  # Layout for the default bar label, driven by `label_position` / `label_side` /
  # `label_overflow` + `opts` (`:fit_ratio`, `:watermark_opacity`) + `arrows`
  # (this bar's connector sides, for `:auto` placement).
  # Returns `nil` for `:none` (and for a milestone in `:fit`, which has no width to
  # fit into), else a map the template applies: `:style` (inline
  # position/width/height/z), `:overflow_class`, `:text_color` (nil = use the
  # bar's contrast color), and `:fit?` / `:fit_bp` (queryable inner span +
  # container-query breakpoint). Always a sibling overlay, so the bar's
  # overflow-hidden never clips it.
  defp bar_label_layout(:none, _side, _overflow, _opts, _bar, _cw, _est, _row, _arrows), do: nil

  # One label in the empty TRACK beside the bar — never clipped (shrink-to-fit,
  # abspos), placed by `track_position/5`. Sits on the chart bg → muted color.
  defp bar_label_layout(:outside, side, _overflow, _opts, bar, cw, est, row, arrows) do
    %{
      style:
        track_position(bar, cw, est, side, arrows) <>
          " height: #{row}px; line-height: #{row}px; z-index: 10;",
      overflow_class: "whitespace-nowrap",
      text_color: "text-base-content/80",
      fit?: false,
      fit_bp: nil
    }
  end

  # A milestone has no bar interior to hold an `:inside` label, so it gets none —
  # the name is in the sidebar. (Matches `:fit`; otherwise the zero-width label
  # would overlap the diamond.) Use `:outside`/`:watermark` to label milestones
  # beside the diamond.
  defp bar_label_layout(
         :inside,
         _side,
         _overflow,
         _opts,
         %{milestone: true},
         _cw,
         _est,
         _row,
         _arrows
       ),
       do: nil

  # Overlaid ON the bar (width = bar width). `:truncate`/`:clip` clip to the bar;
  # `:visible` lets the text spill. `:right` hugs the bar's end. Text color is nil
  # so the template uses the bar's on-color contrast.
  defp bar_label_layout(:inside, side, overflow, _opts, bar, cw, _est, row, _arrows) do
    overflow_class =
      case overflow do
        :clip -> "overflow-hidden whitespace-nowrap text-clip"
        :visible -> "overflow-visible whitespace-nowrap"
        _ -> "truncate"
      end

    %{
      style: inside_style(bar, cw, row, side),
      overflow_class: overflow_class,
      text_color: nil,
      fit?: false,
      fit_bp: nil
    }
  end

  # Like `:inside` (overlaid on the bar, truncated), but the bar becomes a CSS
  # query container and the label's text is hidden when the bar's RENDERED width
  # is at/under `fit_ratio` of the label's estimated width. So a bar wide enough
  # to show ≥ `fit_ratio` of the label keeps it (truncated to whatever fits) and a
  # narrower bar shows a clean bar — decided per-bar in the browser, fill-aware.
  # A milestone has ~no width, so it would always hide → render no label for it.
  defp bar_label_layout(
         :fit,
         _side,
         _overflow,
         _opts,
         %{milestone: true},
         _cw,
         _est,
         _row,
         _arrows
       ),
       do: nil

  defp bar_label_layout(:fit, side, _overflow, opts, bar, cw, est, row, _arrows) do
    %{
      style: inside_style(bar, cw, row, side) <> " container-type: inline-size;",
      overflow_class: "overflow-hidden",
      text_color: nil,
      fit?: true,
      fit_bp: max(round(opts.fit_ratio * est), 1)
    }
  end

  # "Watermark" mode: one copy in the empty track beside the bar (same placement
  # as `:outside`), but rendered big, heavy and italic at a soft opacity — a quiet
  # oversized annotation rather than a loud title. (Started as a tiled background
  # band; trimmed to a single beside-the-bar copy because the tiling read as too
  # busy.)
  defp bar_label_layout(:watermark, side, _overflow, opts, bar, cw, est, row, arrows) do
    font = round(row * 0.72)

    %{
      style:
        track_position(bar, cw, est, side, arrows) <>
          " height: #{row}px; line-height: #{row}px; font-size: #{font}px; " <>
          "font-weight: 800; font-style: italic; letter-spacing: 0.04em; " <>
          "opacity: #{opts.watermark_opacity}; z-index: 10;",
      overflow_class: "whitespace-nowrap",
      text_color: "text-base-content",
      fit?: false,
      fit_bp: nil
    }
  end

  # Shared inline style for a label overlaid on the bar (`:inside` / `:fit`).
  defp inside_style(bar, cw, row, side) do
    align = if side == :right, do: "right", else: "left"

    "left: #{pct(bar.left_px, cw)}%; width: #{pct(bar.width_px, cw)}%; " <>
      "height: #{row}px; line-height: #{row}px; padding: 0 8px; text-align: #{align}; z-index: 11;"
  end

  # CSS rotation for a connector label in the HTML overlay: a vertical segment
  # carries a `label_transform` (the old SVG `rotate(-90 …)`), so the HTML copy
  # rotates -90°; a horizontal one stays upright.
  defp label_rotation(%{label_transform: t}) when is_binary(t), do: " rotate(-90deg)"
  defp label_rotation(_), do: ""

  # Slide the label along its segment (a horizontal leg for detours, a
  # vertical trunk otherwise) until its rect doesn't overlap any
  # non-excluded bar. Candidate positions start at the segment center
  # and expand outward in 6px steps; the first clear candidate wins.
  # If nothing is clear, falls back to the segment center.
  #
  # `route.label_orientation` (:horizontal | :vertical) controls whether
  # the label renders rotated -90°; rotation swaps which axis the rect's
  # long edge sits along, which changes the bbox used for overlap checks.
  defp place_label(%{kind: kind, fixed: fixed, min: seg_min, max: seg_max}, route, ctx) do
    label_w = Map.get(route, :label_width, 0)

    if label_w == 0 do
      center = div(seg_min + seg_max, 2)
      {x, y} = materialize_position(kind, fixed, center)
      {x, y, nil}
    else
      orientation = Map.get(route, :label_orientation, :horizontal)
      {bbox_along, bbox_perp} = label_bbox(label_w, kind, orientation)
      half_along = div(bbox_along, 2)
      half_perp = div(bbox_perp, 2)

      center = div(seg_min + seg_max, 2)
      slide_min = seg_min + half_along
      slide_max = seg_max - half_along

      candidates =
        [center] ++
          for step <- 1..15, offset = step * 6, pos <- [center + offset, center - offset] do
            pos
          end

      feasible =
        Enum.filter(candidates, fn pos -> pos >= slide_min and pos <= slide_max end)

      clear =
        Enum.find(feasible, fn pos ->
          {x, y} = materialize_position(kind, fixed, pos)

          not label_overlaps_any_bar?(x, y, half_along, half_perp, kind, ctx, route.exclude_ids)
        end)

      chosen = clear || center
      {x, y} = materialize_position(kind, fixed, chosen)
      transform = if orientation == :vertical, do: "rotate(-90 #{x} #{y})", else: nil
      {x, y, transform}
    end
  end

  defp materialize_position(:horizontal, fixed_y, slide_x), do: {slide_x, fixed_y}
  defp materialize_position(:vertical, fixed_x, slide_y), do: {fixed_x, slide_y}

  # Returns {bbox_along_segment, bbox_perpendicular_to_segment} in px,
  # accounting for rotation. The text is ~label_w wide × ~10px tall;
  # add a few px of halo margin so placement gives the glyphs + stroke
  # halo a bit of breathing room off intermediate bars.
  defp label_bbox(label_w, :horizontal, :horizontal), do: {label_w + 4, 12}
  defp label_bbox(label_w, :horizontal, :vertical), do: {12, label_w + 4}
  defp label_bbox(label_w, :vertical, :horizontal), do: {12, label_w + 4}
  defp label_bbox(label_w, :vertical, :vertical), do: {label_w + 4, 12}

  defp label_overlaps_any_bar?(x, y, half_along, half_perp, kind, ctx, exclude_ids) do
    {x_min, x_max, y_min, y_max} =
      case kind do
        :horizontal -> {x - half_along, x + half_along, y - half_perp, y + half_perp}
        :vertical -> {x - half_perp, x + half_perp, y - half_along, y + half_along}
      end

    Enum.any?(ctx.bars, fn b ->
      not MapSet.member?(exclude_ids, b.event_id) and
        b.x_left < x_max and b.x_right > x_min and
        b.y_top < y_max and b.y_bottom > y_min
    end)
  end

  # `{left_px, right_px}` of an event's bar AS RENDERED (honoring `min_bar_px`),
  # so connector endpoints attach to the visible bar. A milestone collapses to
  # its center point (the ±10px diamond offset is applied by the caller).
  # Out-of-range falls back to raw temporal coords (the connector is typically
  # not drawn in that case anyway).
  defp rendered_edges(event, {origin, _span} = view, day_px, min_bar_px) do
    case bar_geometry(event, view, day_px, min_bar_px) do
      %{milestone: true, left_px: l} ->
        {l, l}

      %{left_px: l, width_px: w} ->
        {l, l + w}

      _ ->
        {x_px(event.start, origin, day_px),
         x_px(PhoenixLiveGantt.Task.effective_end(event), origin, day_px)}
    end
  end

  defp endpoints_for(type, from_event, to_event, {origin, _span} = view, day_px, min_bar_px) do
    # Connectors attach at gap 0 — the bar's edge, or (for a milestone, where
    # `rendered_edges` collapses to the diamond's CENTER) the diamond center. A
    # bar edge reads as connected at any responsive fill because the shaft SVG
    # stretches in lockstep with the bars. We used to push a milestone's
    # endpoint out by a 10px "diamond clearance", but that 10px is in CONTENT
    # units that the fill STRETCHES — so against the FIXED-px diamond it became a
    # visible gap (the arrow stopping short of the diamond). Attaching at the
    # center instead lets the diamond (raised above the connector layer) sit
    # cleanly on the shaft end + arrowhead.

    # DRAW from the RENDERED bar edges (honoring `min_bar_px`), so a sub-pixel
    # task that renders wider than its true span still has its arrow attach to
    # the bar AS DRAWN rather than emerging from inside it.
    {from_start_px, from_end_px} = rendered_edges(from_event, view, day_px, min_bar_px)
    {to_start_px, to_end_px} = rendered_edges(to_event, view, day_px, min_bar_px)

    # JUDGE backward/invalid from the NATURAL temporal edges, NOT the rendered
    # ones — the conflict is about the schedule, not the min-width-inflated
    # render. (Otherwise a zero-gap FS dep — B starting exactly when A finishes —
    # is wrongly flagged backward because A's 1px sliver pokes past B's start.)
    # Origin is shared, so the relative comparison is unaffected by which it is.
    from_start_nat = x_px(from_event.start, origin, day_px)
    from_end_nat = x_px(PhoenixLiveGantt.Task.effective_end(from_event), origin, day_px)
    to_start_nat = x_px(to_event.start, origin, day_px)
    to_end_nat = x_px(PhoenixLiveGantt.Task.effective_end(to_event), origin, day_px)

    case type do
      :fs ->
        %{
          x1: from_end_px,
          arrow_stop: to_start_px,
          source_exit: :east,
          target_entry: :west,
          backward: conflict?(from_end_nat, to_start_nat)
        }

      :ss ->
        %{
          x1: from_start_px,
          arrow_stop: to_start_px,
          source_exit: :west,
          target_entry: :west,
          backward: conflict?(from_start_nat, to_start_nat)
        }

      :ff ->
        %{
          x1: from_end_px,
          arrow_stop: to_end_px,
          source_exit: :east,
          target_entry: :east,
          backward: conflict?(from_end_nat, to_end_nat)
        }

      :sf ->
        %{
          x1: from_start_px,
          arrow_stop: to_end_px,
          source_exit: :west,
          target_entry: :east,
          backward: conflict?(from_start_nat, to_end_nat)
        }
    end
  end

  # Schedule conflict (invalid / "time travel") — same rule for every
  # dep type: the constraint reference point on the target is earlier
  # in time than the reference point on the source.
  defp conflict?(x1, x2), do: x2 < x1

  # Forward (non-conflicting) path — always three segments. `mid_x` is
  # chosen per exit/entry combination plus bus-aware preferences:
  #
  #   many arrows from same source → trunk aligns on source+elbow
  #   many arrows into same target → trunk aligns on target+-elbow
  #
  # For FS with source and target crammed together (no room for a
  # clean three-segment), we fall back to the midpoint so the
  # visual kink is minimal rather than overshooting past target.
  #
  # When `avoid_collisions` is on, the chosen mid_x is shifted (within
  # its type-valid range) to dodge any unrelated bar the trunk would
  # otherwise pierce.
  defp build_forward_path(x1, y1, arrow_stop, y2, geom, ctx, route) do
    elbow = Map.get(route, :exit_stem, @elbow_px)
    tgt_ms = milestone?(geom.to_event)

    {min_x, max_x} =
      range = mid_valid_range(x1, arrow_stop, route.source_exit, route.target_entry, tgt_ms)

    base_mid =
      choose_mid_x(
        x1,
        arrow_stop,
        route.source_exit,
        route.target_entry,
        geom.source_fanout,
        geom.target_fanin,
        Map.get(route, :label_width, 0),
        elbow,
        tgt_ms
      )

    # Stagger trunk x by lane offset when this arrow is part of a fan-out
    # bus with `bus_stagger_outgoing_px > 0` or fan-in bus with
    # `bus_stagger_incoming_px > 0`. No-op (offset=0) when stagger is off
    # or when this arrow isn't sharing a bus with siblings. CLAMP into the valid
    # range so a stagger lane can't push the trunk past the target-approach floor
    # (which would detach a milestone head) or in front of the exit stem — the
    # milestone floor is baked into `range`/`choose_mid_x`, so collision
    # avoidance, feasibility, and consolidation all see it (no post-clamp that
    # could re-pierce a bar collision avoidance just dodged).
    preferred_mid =
      (base_mid + forward_stagger_offset(geom, route, ctx)) |> max(min_x) |> min(max_x)

    mid_x = maybe_shift_trunk(preferred_mid, y1, y2, range, route.exclude_ids, ctx)

    d = PathFormat.forward(x1, y1, mid_x, y2, arrow_stop)

    # Label lives on the vertical trunk — slide along y to find a clear
    # position if the center would overlap a bar.
    segment = %{
      kind: :vertical,
      fixed: mid_x,
      min: min(y1, y2),
      max: max(y1, y2)
    }

    {label_x, label_y, label_transform} = place_label(segment, route, ctx)
    {d, label_x, label_y, label_transform}
  end

  # Minimum distance from `arrow_stop` to the trunk on the TARGET-entry side. A
  # milestone target's fixed-px arrowhead is nudged `@milestone_edge_px` out, so
  # the final approach leg must be at least that + 2px or the head lands off the
  # shaft at a low fill factor. `@min_approach_px` == `@elbow_px` (10), so a
  # non-milestone target is unchanged. Used uniformly by `choose_mid_x`,
  # `mid_valid_range`, and `forward_fs_needs_detour?` so the floor is part of the
  # routing decision rather than a post-clamp.
  defp target_approach_min(false), do: @min_approach_px
  defp target_approach_min(true), do: @milestone_edge_px + 2

  defp choose_mid_x(x1, arrow_stop, :east, :west, fanout, fanin, _label_w, elbow, tgt_ms) do
    # FS — stems point at each other; trunk lives BETWEEN them.
    #
    # Clearance constraints (enforced via clamp below):
    #   mid_x ≥ x1 + @min_exit_stem_px   — visible exit stem at source
    #   mid_x ≤ arrow_stop - @min_approach_px — arrow marker clears trunk
    #
    # This branch only runs when the gap is wide enough for both
    # minimums — tight gaps are routed via `build_fs_detour_path` in
    # `build_path`, so no degenerate-fallback case is needed here.
    min_mid = x1 + @min_exit_stem_px
    max_mid = arrow_stop - target_approach_min(tgt_ms)

    preferred =
      cond do
        fanout > 1 -> x1 + elbow
        fanin > 1 -> arrow_stop - elbow
        true -> div(x1 + arrow_stop, 2)
      end

    preferred |> max(min_mid) |> min(max_mid)
  end

  defp choose_mid_x(x1, arrow_stop, :west, :west, fanout, fanin, label_w, elbow, tgt_ms) do
    # SS — both stems exit west; trunk sits west of the earliest of
    # the two. Labels ride the vertical trunk, so when present we push
    # the offset further west by label_half + clearance to keep the
    # label out of the source/target bar x-range.
    offset = label_aware_offset(label_w, elbow)
    stem_out = x1 - offset
    stem_in = arrow_stop - max(offset, target_approach_min(tgt_ms))

    cond do
      fanout > 1 -> stem_out
      fanin > 1 -> stem_in
      true -> min(stem_out, stem_in)
    end
  end

  defp choose_mid_x(x1, arrow_stop, :east, :east, fanout, fanin, label_w, elbow, tgt_ms) do
    # FF — both stems exit east; trunk sits east of the latest of
    # the two. Label pushes trunk further east.
    offset = label_aware_offset(label_w, elbow)
    stem_out = x1 + offset
    stem_in = arrow_stop + max(offset, target_approach_min(tgt_ms))

    cond do
      fanout > 1 -> stem_out
      fanin > 1 -> stem_in
      true -> max(stem_out, stem_in)
    end
  end

  defp choose_mid_x(x1, arrow_stop, :west, :east, fanout, fanin, label_w, elbow, tgt_ms) do
    # SF — source exits west, target enters from east. Routing around
    # the right side (east of target_end) keeps the arrow tangent
    # aligned with the target_entry direction. Trunk sits east of
    # target; push further east for label.
    label_offset = label_aware_offset(label_w, elbow)
    stem_out = x1 - elbow
    stem_in = arrow_stop + max(label_offset, target_approach_min(tgt_ms))

    cond do
      fanout > 1 -> stem_out
      fanin > 1 -> stem_in
      true -> max(stem_out, stem_in)
    end
  end

  # Offset for same-side trunks (SS/FF/SF). With no label, just the
  # elbow; with a label, at least half the label width plus clearance
  # so the trunk-centered label clears the nearest bar edge.
  #
  # Known limit: a WIDE label on an SS/SF connector whose source/target sits at
  # the extreme left of the window can push the trunk past the `@axis_pad_px`
  # (16px) left margin and clip. The pad covers the bare elbow stub, not an
  # arbitrarily wide label; widen `date_range`/the window a touch, or shorten the
  # label, if a left-edge labeled SS/SF connector clips.
  defp label_aware_offset(0, elbow), do: elbow
  defp label_aware_offset(label_w, elbow), do: max(elbow, div(label_w, 2) + @label_clearance_px)

  # When to use the detour instead of the straight 3-segment:
  #
  #   * The gap between source-end-east-tip and target-approach-west
  #     must be at least a full exit stem + full approach stem, else
  #     the 3-segment would squish one side.
  #   * When the arrow carries a label, the gap must ALSO be wide
  #     enough for the label to sit between the bars on the vertical
  #     trunk without overlapping them. If not, switch to detour —
  #     the label then sits on the horizontal leg.
  defp forward_fs_needs_detour?(x1, arrow_stop, label_w, tgt_ms) do
    gap = arrow_stop - x1
    # Tight gaps into a milestone need the wider approach floor, so they fall to
    # the detour (which handles the milestone approach via its own `base_entry`)
    # rather than a forward path that can't fit exit stem + approach.
    base_min = @min_exit_stem_px + target_approach_min(tgt_ms)

    label_min =
      if label_w > 0,
        do: label_w + @label_clearance_px * 2,
        else: 0

    gap < max(base_min, label_min)
  end

  # Five-segment FS detour — used when the 3-segment can't route cleanly.
  # Covers two cases with the SAME shape:
  #
  #   1. Backward (schedule conflict): target starts earlier than source
  #      ends; the forward stems would point into each other. Rendered
  #      as invalid (dashed red) elsewhere based on `conflict?`.
  #   2. Forward but tight: source and target are valid order but too
  #      close for the 3-segment to fit clean exit + approach stems.
  #      Still a normal (solid) arrow — just a richer shape.
  #
  # Shape: east stem out of source → vertical to row border → horizontal
  # across intermediate rows → vertical to target row → east stem into
  # target. Every segment advances toward the target for the forward
  # case; for backward some segments must retrace.
  #
  # `lane` staggers detour_y when multiple backward arrows share the
  # same source row and direction — otherwise they'd draw on top of
  # each other. Lane 0 sits exactly on the border; each subsequent
  # lane is 2px further into the adjacent row. Forward-tight arrows
  # get lane 0 (they're not in the backward-lanes map) so a tight-FS
  # bus stays visually merged.
  #
  # Collision avoidance: the final vertical at `stem_in` is the segment
  # most likely to cross unrelated bars. Rather than shifting stem_in
  # west (which makes the horizontal detour tail absurdly long), we
  # push `detour_y` TOWARD the target past any intermediate bar the
  # final vertical would otherwise pierce — i.e. the horizontal leg
  # routes *under* / *over* the obstruction instead of *across* it.
  defp build_fs_detour_path(x1, y1, arrow_stop, y2, geom, ctx, route) do
    # Stems default to the connector's exit/entry override (or the
    # component-level elbow). For labeled detours we widen symmetrically
    # so the leg's horizontal extent fits the label.
    label_w = Map.get(route, :label_width, 0)
    gap = arrow_stop - x1
    base_exit = Map.get(route, :exit_stem, @elbow_px)

    # A milestone target's arrowhead is nudged @milestone_edge_px OUT to the
    # diamond edge — a fixed SCREEN px. The head rides the final approach segment,
    # so that segment (in VIEWBOX px) must be at least that long, or at a low fill
    # factor (the `:min5` scroll case, where the approach renders ~1:1) the nudged
    # head overshoots the trunk and floats off, disconnected. Give a milestone
    # target an approach stem a hair longer than the nudge so the head always
    # lands ON the shaft, at every zoom. (Longer paths at a high fill are fine.)
    base_entry =
      if milestone?(geom.to_event) do
        max(Map.get(route, :entry_stem, @elbow_px), @milestone_edge_px + 2)
      else
        Map.get(route, :entry_stem, @elbow_px)
      end

    {exit_offset, entry_offset} =
      if label_w > 0 do
        needed = div(max(gap, 0) + label_w + @label_clearance_px * 2, 2)
        {max(base_exit, needed), max(base_entry, needed)}
      else
        {base_exit, base_entry}
      end

    # Stagger source/target stems independently — the detour shape has
    # two distinct x's, so source-side fan-out and target-side fan-in
    # stagger can both apply to the same arrow without conflicting.
    {src_stagger, tgt_stagger} = stagger_x_offsets(geom, route, ctx)

    stem_out = x1 + exit_offset + src_stagger
    stem_in = arrow_stop - entry_offset + tgt_stagger

    # Detour direction: `:auto` picks the natural side (same direction as
    # target); `:above` / `:below` force the detour above or below the
    # source row regardless of target position.
    forced_side = Map.get(route, :detour_side, :auto)

    # Use the source row's ACTUAL top/bottom border (not y1 ± row_px/2),
    # because Y stagger can offset y1 anywhere within the bar's height.
    # If we anchored detour_y to y1 + row_px/2 we'd push detour_y into
    # the NEXT row when y1 is at the source bar's bottom edge — and
    # stem_out's y span would then reach into intermediate bars.
    src_top = geom.from_pos.top
    src_bottom = src_top + ctx.row_px

    {detour_base, dir_sign} =
      case {forced_side, y2 > y1} do
        {:above, _} -> {src_top, -1}
        {:below, _} -> {src_bottom, 1}
        {_, true} -> {src_bottom, 1}
        {_, false} -> {src_top, -1}
      end

    preferred_detour_y = detour_base + dir_sign * geom.lane * 2

    # Compute the label bounding box BEFORE pushing, so the push can
    # account for it (keep the label rect out of obstructing bars too,
    # not just the line).
    leg_mid = div(stem_out + stem_in, 2)

    label_box =
      if label_w > 0 do
        half_w = div(label_w + 4, 2)

        %{
          x_min: leg_mid - half_w,
          x_max: leg_mid + half_w,
          half_height: 6
        }
      end

    detour_y =
      push_detour_past_obstructions(
        preferred_detour_y,
        stem_in,
        y2,
        dir_sign,
        ctx.row_px,
        route.exclude_ids,
        ctx,
        label_box
      )

    # After detour_y is fixed, stem_out's vertical span (y1 to detour_y)
    # may now reach into intermediate rows. If a bar at stem_out's x sits
    # in that span, the vertical line pierces it. Similarly stem_in's
    # vertical span (detour_y to y2). Shifting a stem extends the
    # horizontal leg, which may then pierce a bar at detour_y.
    #
    # The fixed point is: stems and detour_y consistent — neither stem
    # pierces a bar in its column-y-span, AND detour_y doesn't sit
    # inside any bar overlapping the leg's x range. Converge by
    # alternating shifts and pushes until stable.
    {stem_out, stem_in, detour_y} =
      converge_detour_geometry(
        stem_out,
        stem_in,
        detour_y,
        x1,
        y1,
        arrow_stop,
        y2,
        dir_sign,
        route,
        ctx
      )

    # Pick the descent: the standard 5-segment detour, or — when its stem can
    # only hug a bar edge on a tight staircase — the 7-segment outer-gutter.
    {d, leg_y, leg_left} =
      fs_detour_or_gutter(
        x1,
        y1,
        stem_out,
        stem_in,
        detour_y,
        y2,
        arrow_stop,
        src_bottom,
        dir_sign,
        route,
        ctx
      )

    # Label lives on the (bottom) horizontal leg — slide along x to find
    # a clear position if the center would overlap a bar.
    segment = %{
      kind: :horizontal,
      fixed: leg_y,
      min: min(leg_left, stem_in),
      max: max(leg_left, stem_in)
    }

    {label_x, label_y, label_transform} = place_label(segment, route, ctx)
    {d, label_x, label_y, label_transform}
  end

  # Build the descent for an FS detour, returning `{d, leg_y, leg_left}` (the
  # latter two locate the horizontal leg for label placement).
  #
  # If the descending stem can ONLY hug a bar's edge — a tight staircase of
  # consecutive bars with no channel anywhere — and a clear OUTER gutter exists
  # left of those bars, route the descent down the gutter (a 7-segment path) so
  # the trunk stays fully clear of every task rather than running flush along an
  # edge. Only for a forward-and-down skip; everything else keeps the standard
  # 5-segment detour.
  defp fs_detour_or_gutter(
         x1,
         y1,
         stem_out,
         stem_in,
         detour_y,
         y2,
         arrow_stop,
         src_bottom,
         dir_sign,
         route,
         ctx
       ) do
    descent_bars = bars_crossing_span(ctx.bars, y1, y2, route.exclude_ids)

    gutter_x =
      if Map.get(ctx, :avoid_collisions, true) and dir_sign > 0 and arrow_stop > x1 and
           descent_bars != [] and
           trunk_clearance(stem_out, descent_bars) < @trunk_min_clearance_px do
        outer_gutter_x(descent_bars)
      end

    if gutter_x do
      exit_stub = x1 + Map.get(route, :exit_stem, @elbow_px)

      # Exit east, drop to the source border, hop out to the gutter, then
      # descend the gutter STRAIGHT to the target row and run across in one
      # horizontal — a single bottom corner, no extra step-down. It's a plain
      # detour whose two stems are the exit stub and the gutter, so the gutter
      # column is clear top-to-bottom (`gutter_x` is left of every obstacle).
      {PathFormat.detour(x1, y1, exit_stub, src_bottom, gutter_x, y2, arrow_stop), y2, gutter_x}
    else
      {PathFormat.detour(x1, y1, stem_out, detour_y, stem_in, y2, arrow_stop), detour_y, stem_out}
    end
  end

  # Precompute a lane index per backward FS connector so multiple arrows
  # with the same source row + direction don't stack on top of each
  # other. Returns a map keyed by `{from_id, to_id, type}` → integer.
  defp assign_backward_lanes(
         normalized_connectors,
         events_by_id,
         row_positions,
         view,
         day_px,
         min_bar_px
       ) do
    normalized_connectors
    |> Enum.filter(fn c ->
      from_event = Map.get(events_by_id, c.from)
      to_event = Map.get(events_by_id, c.to)
      from_pos = Map.get(row_positions.positions, c.from)
      to_pos = Map.get(row_positions.positions, c.to)

      cond do
        is_nil(from_event) or is_nil(to_event) ->
          false

        is_nil(from_pos) or is_nil(to_pos) ->
          false

        c.type != :fs ->
          false

        true ->
          %{backward: backward} =
            endpoints_for(c.type, from_event, to_event, view, day_px, min_bar_px)

          backward
      end
    end)
    |> Enum.group_by(fn c ->
      from_pos = row_positions.positions[c.from]
      to_pos = row_positions.positions[c.to]
      dir = if to_pos.center > from_pos.center, do: :down, else: :up
      {c.from, dir}
    end)
    |> Enum.flat_map(fn {_key, group} ->
      group
      |> Enum.with_index()
      |> Enum.map(fn {c, idx} -> {{c.from, c.to, c.type}, idx} end)
    end)
    |> Map.new()
  end

  # Precompute lane indices for FORWARD bus stagger. For each
  # `{event_id, side, :outgoing | :incoming}` bus, sort the connectors
  # in the bus by the OTHER end's row position so adjacent rows get
  # adjacent lanes (visually monotonic fanning). Returns:
  #
  #   %{
  #     source: %{conn_key => integer lane in source's outgoing bus},
  #     target: %{conn_key => integer lane in target's incoming bus}
  #   }
  #
  # `conn_key` = `{from_id, to_id, type}`. Both maps are looked up in
  # `connector_path/2` and stashed on `geom` so per-arrow path builders
  # can apply the stagger without re-walking the connector list.
  defp assign_bus_lanes(normalized_connectors, row_positions) do
    positions = row_positions.positions

    conn_data =
      normalized_connectors
      |> Enum.filter(fn c ->
        Map.has_key?(positions, c.from) and Map.has_key?(positions, c.to)
      end)
      |> Enum.map(fn c ->
        %{
          conn_key: {c.from, c.to, c.type},
          src_bus: {c.from, source_side_for(c.type), :outgoing},
          tgt_bus: {c.to, target_side_for(c.type), :incoming},
          src_pos: positions[c.from],
          tgt_pos: positions[c.to]
        }
      end)

    %{
      source: assign_lanes_for_bus(conn_data, :src_bus, :tgt_pos),
      target: assign_lanes_for_bus(conn_data, :tgt_bus, :src_pos)
    }
  end

  defp assign_lanes_for_bus(conn_data, bus_field, sort_pos_field) do
    conn_data
    |> Enum.group_by(&Map.get(&1, bus_field))
    |> Enum.flat_map(fn {_bus_key, members} ->
      bus_size = length(members)

      members
      |> Enum.sort_by(&Map.get(&1, sort_pos_field).top)
      |> Enum.with_index()
      |> Enum.map(fn {data, lane} -> {data.conn_key, {lane, bus_size}} end)
    end)
    |> Map.new()
  end

  # Returns `{source_offset, target_offset}` x-offsets for bus stagger.
  # Positive = east, negative = west. Each side's offset is independent:
  # source's outgoing-bus stagger affects the source-side stem; target's
  # incoming-bus stagger affects the target-side stem. The forward 3-seg
  # path has a single trunk x and uses whichever side is biased (matching
  # `choose_mid_x`'s fanout-wins precedence). The 5-seg detour path has
  # distinct stem_out and stem_in x's and applies both offsets.
  defp stagger_x_offsets(geom, route, ctx) do
    src_stagger = task_stagger(geom.from_event, :outgoing, ctx)
    tgt_stagger = task_stagger(geom.to_event, :incoming, ctx)

    source_offset =
      if geom.source_fanout > 1 and src_stagger > 0 do
        side_sign(route.source_exit) * geom.source_lane * src_stagger
      else
        0
      end

    target_offset =
      if geom.target_fanin > 1 and tgt_stagger > 0 do
        side_sign(route.target_entry) * geom.target_lane * tgt_stagger
      else
        0
      end

    {source_offset, target_offset}
  end

  # Pick the right stagger offset for the forward 3-seg path's single
  # trunk x. Mirrors `choose_mid_x`'s fanout-wins precedence: when source
  # has fan-out, trunk is biased toward source so the source-side stagger
  # applies; otherwise the target-side stagger applies.
  defp forward_stagger_offset(geom, route, ctx) do
    {src_off, tgt_off} = stagger_x_offsets(geom, route, ctx)

    cond do
      geom.source_fanout > 1 -> src_off
      geom.target_fanin > 1 -> tgt_off
      true -> 0
    end
  end

  defp task_stagger(event, :outgoing, ctx) do
    case event.extra do
      %{bus_stagger_outgoing_px: n} when is_integer(n) and n >= 0 -> n
      _ -> ctx.bus_stagger_outgoing_px
    end
  end

  defp task_stagger(event, :incoming, ctx) do
    case event.extra do
      %{bus_stagger_incoming_px: n} when is_integer(n) and n >= 0 -> n
      _ -> ctx.bus_stagger_incoming_px
    end
  end

  defp side_sign(:east), do: 1
  defp side_sign(:west), do: -1

  # Pick this end's attach y. When stagger is active and the bus has
  # multiple arrows, distribute lanes evenly across the bar's flat
  # region (between the rounded corners) so every arrow emerges from
  # inside the bar's visible area. Otherwise fall back to the
  # configured attach mode (smart / type_zoned / center).
  defp compute_attach_y(:source, geom, side, attach_class, ctx) do
    if y_stagger_active?(geom, :source, ctx) do
      bar_distributed_y(geom.from_pos, geom.source_lane, geom.source_bus_size, ctx)
    else
      attach_y(geom.from_pos, geom.from_event, side, attach_class, ctx)
    end
  end

  defp compute_attach_y(:target, geom, side, attach_class, ctx) do
    if y_stagger_active?(geom, :target, ctx) do
      bar_distributed_y(geom.to_pos, geom.target_lane, geom.target_bus_size, ctx)
    else
      attach_y(geom.to_pos, geom.to_event, side, attach_class, ctx)
    end
  end

  defp y_stagger_active?(geom, :source, ctx) do
    not milestone?(geom.from_event) and
      geom.source_bus_size > 1 and
      task_stagger(geom.from_event, :outgoing, ctx) > 0
  end

  defp y_stagger_active?(geom, :target, ctx) do
    not milestone?(geom.to_event) and
      geom.target_bus_size > 1 and
      task_stagger(geom.to_event, :incoming, ctx) > 0
  end

  # Distribute lanes evenly across the bar's flat region (between rounded
  # corners). Lane 0 lands at the top of the flat region, lane (N-1) at
  # the bottom. Result is symmetric around bar center. With lane order
  # = sort by other-end row top, this also makes upper-row arrows
  # naturally emerge from upper part of the bar (and vice versa).
  #
  # The inset is `corner_clearance + @stroke_buffer_px` so the line's
  # stroke (up to ~2.25px for critical) stays fully inside the bar's
  # flat edge, not bleeding into the rounded corner where the bar's
  # right/left edge curves inward.
  @stroke_buffer_px 2

  defp bar_distributed_y(pos, lane, bus_size, ctx) do
    bar_top = pos.top + 4
    bar_height = max(ctx.row_px - 8, 8)
    inset = ctx.bus_stagger_corner_clearance_px + @stroke_buffer_px

    flat_top = bar_top + inset
    flat_height = max(bar_height - 2 * inset, 0)

    cond do
      bus_size <= 1 or flat_height == 0 ->
        bar_top + div(bar_height, 2)

      true ->
        # Use integer floor-div for the per-lane spacing so every gap is
        # the same number of pixels (`flat_height / (n-1)` rounded down).
        # Then center the resulting spread inside the flat region by
        # splitting the leftover gap between top and bottom margins —
        # this keeps the lanes symmetric around the bar center even when
        # `flat_height` doesn't divide evenly.
        spacing = div(flat_height, bus_size - 1)
        leftover = flat_height - spacing * (bus_size - 1)
        margin = div(leftover, 2)
        flat_top + margin + lane * spacing
    end
  end

  # -- Bar-collision avoidance --
  #
  # Builds a flat list of every bar's pixel rectangle, then shifts the
  # trunk x of forward arrows (and the final vertical of backward FS
  # arrows) to dodge intermediate-row bars that lie on the preferred x.
  #
  # The shift is bounded by each dep type's valid range so the arrow's
  # shape family doesn't break (FS trunks stay between source and
  # target; SS trunks stay west of both; FF/SF trunks stay east of both).
  # If no bar-free x exists in the valid range, we keep the preferred
  # placement — an arrow crossing a bar is better than a broken shape.

  defp compute_bar_obstacles(sorted_events, row_positions, view, day_px, row_px, min_bar_px) do
    sorted_events
    |> Enum.flat_map(fn event ->
      pos = Map.get(row_positions.positions, event.id)
      bar = bar_geometry(event, view, day_px, min_bar_px)

      # Defensive: an out-of-window bar has no left_px/width_px/milestone keys.
      # Partition already filters these out (it shares `view` with bar_geometry),
      # so this only guards against a future divergence — skip rather than crash.
      if bar[:out_of_range] do
        []
      else
        # Milestone diamonds are ~16px rotated 45° — use a symmetric 11px
        # half-width hit box around their center (which equals bar.left_px
        # since width_px == 0 for milestones).
        {x_left, x_right} =
          if bar.milestone do
            {bar.left_px - 11, bar.left_px + 11}
          else
            {bar.left_px, bar.left_px + bar.width_px}
          end

        [
          %{
            event_id: event.id,
            y_top: pos.top + 1,
            y_bottom: pos.top + row_px - 1,
            x_left: x_left,
            x_right: x_right
          }
        ]
      end
    end)
  end

  # Returns a {min_x, max_x} range where mid_x can legally land for the
  # given exit/entry combination. The forward path shape `M x1 H mid V y2
  # H x2` requires mid_x to sit on the correct side of each stem for the
  # arrow tangent to orient properly.
  defp mid_valid_range(x1, arrow_stop, :east, :west, tgt_ms) do
    # FS — between source_exit (min stem) and target_approach (arrow
    # marker clearance). Matches the clamp range used in `choose_mid_x`
    # so collision avoidance shifts the trunk within the same shape-
    # preserving bounds rather than into arrowhead territory. The
    # target-approach floor widens for a milestone target.
    {x1 + @min_exit_stem_px, arrow_stop - target_approach_min(tgt_ms)}
  end

  defp mid_valid_range(x1, arrow_stop, :west, :west, tgt_ms) do
    # SS — west of both stems (and ≥ the target approach floor west of arrow_stop).
    cap = min(x1 - @elbow_px, arrow_stop - target_approach_min(tgt_ms))
    {cap - 10_000, cap}
  end

  defp mid_valid_range(x1, arrow_stop, :east, :east, tgt_ms) do
    # FF — east of both stems (and ≥ the target approach floor east of arrow_stop).
    floor = max(x1 + @elbow_px, arrow_stop + target_approach_min(tgt_ms))
    {floor, floor + 10_000}
  end

  defp mid_valid_range(x1, arrow_stop, :west, :east, tgt_ms) do
    # SF — east of max(x1, arrow_stop), routing around the right side.
    floor = max(x1 + @elbow_px, arrow_stop + target_approach_min(tgt_ms))
    {floor, floor + 10_000}
  end

  # Shift `preferred` to the nearest bar-free x in `[min_x, max_x]`.
  # If the preferred is already clean, or collision avoidance is off,
  # or no candidate is clean, return preferred unchanged. The clearance
  # target/floor (@trunk_clearance_px / @trunk_min_clearance_px) live up top
  # with the other routing constants.
  defp maybe_shift_trunk(preferred, _y1, _y2, _range, _exclude, %{avoid_collisions: false}),
    do: preferred

  defp maybe_shift_trunk(preferred, _y1, _y2, _range, _exclude, %{bars: []}), do: preferred

  defp maybe_shift_trunk(preferred, y1, y2, {min_x, max_x}, exclude_ids, ctx) do
    bars = bars_crossing_span(ctx.bars, y1, y2, exclude_ids)

    # Hard chart boundary — never push the trunk past the right edge of the SVG
    # canvas (where it would be invisible).
    max_x = min(max_x, Map.get(ctx, :content_width, max_x))

    cond do
      bars == [] ->
        preferred

      # Already comfortably clear — leave it where the router wanted it.
      trunk_clearance(preferred, bars) >= @trunk_clearance_px ->
        preferred

      # Aim for comfortable clearance; settle for a 1px sliver only if the gap is
      # too tight; otherwise leave `preferred` to pass through the bar.
      true ->
        clear_trunk_x(preferred, bars, @trunk_clearance_px, min_x, max_x) ||
          clear_trunk_x(preferred, bars, @trunk_min_clearance_px, min_x, max_x) ||
          preferred
    end
  end

  # Smallest horizontal gap between `x` and any bar edge; negative when `x` is
  # inside a bar (the trunk would pierce it). `bars` is non-empty.
  defp trunk_clearance(x, bars) do
    bars
    |> Enum.map(fn b ->
      cond do
        x <= b.x_left -> b.x_left - x
        x >= b.x_right -> x - b.x_right
        true -> -min(x - b.x_left, b.x_right - x)
      end
    end)
    |> Enum.min()
  end

  # Nearest x to `preferred` within `[min_x, max_x]` keeping at least `clearance`
  # from every bar; nil if none. Candidates are each bar edge pushed out by
  # `clearance`, plus the range bounds.
  defp clear_trunk_x(preferred, bars, clearance, min_x, max_x) do
    (Enum.flat_map(bars, fn b -> [b.x_left - clearance, b.x_right + clearance] end) ++
       [min_x, max_x])
    |> Enum.filter(fn x ->
      x >= min_x and x <= max_x and trunk_clearance(x, bars) >= clearance
    end)
    |> Enum.min_by(fn x -> abs(x - preferred) end, fn -> nil end)
  end

  # Clear x for an outer-gutter descent: a full elbow left of the leftmost
  # obstacle (which puts it left of ALL of them), as long as that stays on the
  # canvas. `nil` → no usable gutter, caller keeps the standard detour.
  #
  # A full elbow (not just the trunk clearance) matters for SIBLINGS: another
  # arrow from the same source that enters the leftmost obstacle approaches it
  # from `left - elbow`. Landing the gutter there makes the two SHARE the
  # descending line — the sibling branches off toward its target while the
  # gutter continues down — instead of the gutter crossing the sibling's
  # approach shaft and arrowhead. (With no sibling it's just a slightly roomier
  # gutter; still left of every obstacle, so still clear.)
  defp outer_gutter_x(bars) do
    left = (bars |> Enum.map(& &1.x_left) |> Enum.min()) - @elbow_px

    if left >= @axis_pad_px and trunk_clearance(left, bars) >= @trunk_min_clearance_px, do: left
  end

  # Filter obstacles down to those whose y-range overlaps the trunk's
  # y-span — every other bar is irrelevant for this arrow.
  defp bars_crossing_span(bars, y_a, y_b, exclude_ids) do
    {y_low, y_high} = if y_a <= y_b, do: {y_a, y_b}, else: {y_b, y_a}

    Enum.filter(bars, fn b ->
      not MapSet.member?(exclude_ids, b.event_id) and
        b.y_top < y_high and b.y_bottom > y_low
    end)
  end

  defp trunk_collides?(x, bars) do
    Enum.any?(bars, fn b -> b.x_left < x and x < b.x_right end)
  end

  # Candidate xs = the left and right edges (± 3px margin) of every bar
  # currently blocking the trunk, sorted by distance from `preferred`.
  # Picking the closest clean candidate keeps the visual deviation small.
  defp candidate_xs(preferred, bars) do
    margin = 3

    bars
    |> Enum.flat_map(fn b -> [b.x_left - margin, b.x_right + margin] end)
    |> Enum.uniq()
    |> Enum.sort_by(fn x -> abs(x - preferred) end)
  end

  # Push detour_y past obstructing bars. Two obstacle flavors:
  #
  #   Line: the final vertical at x=stem_in spanning [detour_y, y2]
  #         must clear any bar whose x contains stem_in. Push past
  #         deepest (down) / highest (up) bar's edge.
  #
  #   Label: when the arrow carries a label, its rect (centered on the
  #          horizontal leg at detour_y, ~12px tall) must also clear
  #          bars whose x overlaps the rect's x-range. Push past such
  #          bars by half the rect height plus margin.
  #
  # Line margin is a quarter of a row-height so the line visibly
  # clears the row below/above the obstructing bar (not just 1px).
  defp push_detour_past_obstructions(
         preferred,
         _stem_in,
         _y2,
         _dir_sign,
         _row_px,
         _exclude_ids,
         %{avoid_collisions: false},
         _label_box
       ),
       do: preferred

  defp push_detour_past_obstructions(
         preferred,
         _stem_in,
         _y2,
         _dir_sign,
         _row_px,
         _exclude_ids,
         %{bars: []},
         _label_box
       ),
       do: preferred

  defp push_detour_past_obstructions(
         preferred,
         stem_in,
         y2,
         dir_sign,
         row_px,
         exclude_ids,
         ctx,
         label_box
       ) do
    # Per-connector bar_clearance is folded into ctx.line_margin in
    # build_path; falls back to row_px/4 when no override is set.
    clearance = Map.get(ctx, :line_margin) || div(row_px, 4)

    obstacle_pushes =
      collect_obstacle_pushes(
        preferred,
        stem_in,
        y2,
        dir_sign,
        exclude_ids,
        ctx,
        label_box,
        clearance
      )

    if obstacle_pushes == [] do
      preferred
    else
      # Cap at the TARGET ROW's near boundary minus a small clearance:
      # `target_row_top - clearance` when going down, mirror going up.
      # NOT `y2 - row_px/2` — that only equals the row top when y2 is at
      # row center, which is no longer true once Y stagger or bar-edge
      # attach push y2 away from the center (a trap where detour_y could
      # land inside the bar one row above target).
      cap = detour_cap(y2, dir_sign, clearance, ctx)

      if dir_sign > 0 do
        obstacle_pushes |> Enum.max() |> min(cap)
      else
        obstacle_pushes |> Enum.min() |> max(cap)
      end
    end
  end

  # Cap is the target ROW's near boundary: detour_y must not cross into
  # the target row from above (when going down) or from below (when
  # going up). Once at the boundary, stem_in's vertical from detour_y
  # to y2 cleanly enters the target row. `ctx.target_row_top` is set
  # per connector in `build_path`.
  defp detour_cap(y2, dir_sign, _clearance, ctx) do
    case Map.get(ctx, :target_row_top) do
      nil ->
        # Legacy fallback: assumes y2 ≈ row center.
        if dir_sign > 0, do: y2 - div(ctx.row_px, 2), else: y2 + div(ctx.row_px, 2)

      top ->
        if dir_sign > 0, do: top, else: top + ctx.row_px
    end
  end

  defp collect_obstacle_pushes(
         preferred,
         stem_in,
         y2,
         dir_sign,
         exclude,
         ctx,
         label_box,
         line_margin
       ) do
    {span_low, span_high} = if preferred <= y2, do: {preferred, y2}, else: {y2, preferred}
    label_half_h = if label_box, do: label_box.half_height, else: 0
    label_margin = label_half_h + 2

    line_pushes =
      ctx.bars
      |> line_obstacles(stem_in, span_low, span_high, exclude)
      |> Enum.map(&push_past_bar(&1, dir_sign, line_margin))

    label_pushes =
      if label_box do
        ctx.bars
        |> label_obstacles(label_box, span_low, span_high, label_half_h, exclude)
        |> Enum.map(&push_past_bar(&1, dir_sign, label_margin))
      else
        []
      end

    line_pushes ++ label_pushes
  end

  defp line_obstacles(bars, stem_in, span_low, span_high, exclude) do
    Enum.filter(bars, fn b ->
      not MapSet.member?(exclude, b.event_id) and
        b.x_left < stem_in and stem_in < b.x_right and
        b.y_top < span_high and b.y_bottom > span_low
    end)
  end

  defp label_obstacles(bars, label_box, span_low, span_high, half_h, exclude) do
    Enum.filter(bars, fn b ->
      not MapSet.member?(exclude, b.event_id) and
        b.x_left < label_box.x_max and b.x_right > label_box.x_min and
        b.y_top < span_high + half_h and b.y_bottom > span_low - half_h
    end)
  end

  defp push_past_bar(bar, 1, margin), do: bar.y_bottom + margin
  defp push_past_bar(bar, -1, margin), do: bar.y_top - margin

  # Iteratively reshape the detour until stems and detour_y are mutually
  # consistent — neither stem's vertical pierces a bar, AND the
  # horizontal leg at detour_y doesn't sit inside any bar overlapping
  # the leg's x range. Each round:
  #   1. Shift stems based on current detour_y.
  #   2. Push detour_y based on the new stems' leg extent.
  #   3. If anything moved, repeat.
  # Bounded by `max_iters` so a pathological cycle can't loop forever.
  defp converge_detour_geometry(
         stem_out,
         stem_in,
         detour_y,
         x1,
         y1,
         arrow_stop,
         y2,
         dir_sign,
         route,
         ctx,
         iters_left \\ 8
       )

  defp converge_detour_geometry(stem_out, stem_in, detour_y, _, _, _, _, _, _, _, 0),
    do: {stem_out, stem_in, detour_y}

  defp converge_detour_geometry(
         stem_out,
         stem_in,
         detour_y,
         x1,
         y1,
         arrow_stop,
         y2,
         dir_sign,
         route,
         ctx,
         iters_left
       ) do
    new_stem_out = maybe_shift_stem_out(stem_out, x1, y1, detour_y, route, ctx)
    new_stem_in = maybe_shift_stem_in(stem_in, arrow_stop, detour_y, y2, route, ctx)

    new_detour_y =
      push_detour_for_horizontal_leg(
        detour_y,
        new_stem_out,
        new_stem_in,
        y2,
        dir_sign,
        ctx.row_px,
        route.exclude_ids,
        ctx
      )

    if new_stem_out == stem_out and new_stem_in == stem_in and new_detour_y == detour_y do
      {stem_out, stem_in, detour_y}
    else
      converge_detour_geometry(
        new_stem_out,
        new_stem_in,
        new_detour_y,
        x1,
        y1,
        arrow_stop,
        y2,
        dir_sign,
        route,
        ctx,
        iters_left - 1
      )
    end
  end

  # After stems are shifted, the horizontal leg from stem_out to stem_in
  # at detour_y may now overlap unrelated bars whose y range contains
  # detour_y AND whose x range overlaps the leg. Push detour_y past
  # those bars (toward target). Capped at y2 ± row_px/2 like the main
  # push so the detour doesn't enter the target row.
  defp push_detour_for_horizontal_leg(
         preferred,
         stem_out,
         stem_in,
         y2,
         dir_sign,
         row_px,
         exclude_ids,
         ctx
       ) do
    if not Map.get(ctx, :avoid_collisions, true) do
      preferred
    else
      leg_left = min(stem_out, stem_in)
      leg_right = max(stem_out, stem_in)
      margin = Map.get(ctx, :line_margin) || div(row_px, 4)

      # Cap at target ROW boundary, not `y2 ± row_px/2` (see comment in
      # `detour_cap/4` — that legacy expression assumed y2 was at row
      # center, which Y stagger breaks).
      cap = detour_cap(y2, dir_sign, margin, ctx)

      iterate_horizontal_push(
        preferred,
        leg_left,
        leg_right,
        dir_sign,
        margin,
        exclude_ids,
        ctx.bars,
        cap,
        # iteration limit so a pathological case can't loop forever
        10
      )
    end
  end

  # Repeatedly push detour_y past any bar that intersects the leg, until
  # no more bars intersect or we hit the cap or iteration limit. One pass
  # only handles the immediate obstruction; pushing past it can land us
  # inside the NEXT bar in the next row, so we have to re-check.
  defp iterate_horizontal_push(preferred, _ll, _lr, _dir, _m, _ex, _bars, _cap, 0),
    do: preferred

  defp iterate_horizontal_push(
         preferred,
         leg_left,
         leg_right,
         dir_sign,
         margin,
         exclude_ids,
         bars,
         cap,
         iters_left
       ) do
    pierced =
      Enum.filter(bars, fn b ->
        not MapSet.member?(exclude_ids, b.event_id) and
          b.x_left < leg_right and b.x_right > leg_left and
          b.y_top < preferred and b.y_bottom > preferred
      end)

    case pierced do
      [] ->
        preferred

      _ ->
        new =
          pierced
          |> Enum.map(&push_past_bar(&1, dir_sign, margin))
          |> then(fn ps -> if dir_sign > 0, do: Enum.max(ps), else: Enum.min(ps) end)

        capped =
          if dir_sign > 0,
            do: min(new, cap),
            else: max(new, cap)

        if capped == preferred do
          preferred
        else
          iterate_horizontal_push(
            capped,
            leg_left,
            leg_right,
            dir_sign,
            margin,
            exclude_ids,
            bars,
            cap,
            iters_left - 1
          )
        end
    end
  end

  # When detour_y is pushed deep past obstructions, stem_out's vertical
  # span (y1 → detour_y) can extend through several intermediate rows.
  # If a bar at stem_out's preferred x sits in that span, the vertical
  # line pierces it. Shift stem_out to the nearest bar-edge x that's
  # clean — preferring east (positive offset) since stem_out must satisfy
  # `stem_out > x1` for the FS detour shape to remain coherent.
  defp maybe_shift_stem_out(preferred, x1, y1, detour_y, route, ctx) do
    if not Map.get(ctx, :avoid_collisions, true) do
      preferred
    else
      shift_stem(preferred, y1, detour_y, route.exclude_ids, ctx, fn x -> x > x1 end)
    end
  end

  # Same idea for stem_in's vertical (detour_y → y2). The constraint here
  # is `stem_in < arrow_stop` (must be west of the arrow tip).
  defp maybe_shift_stem_in(preferred, arrow_stop, detour_y, y2, route, ctx) do
    if not Map.get(ctx, :avoid_collisions, true) do
      preferred
    else
      shift_stem(preferred, detour_y, y2, route.exclude_ids, ctx, fn x -> x < arrow_stop end)
    end
  end

  defp shift_stem(preferred, y_a, y_b, exclude_ids, ctx, valid_fn) do
    bars_in_span = bars_crossing_span(ctx.bars, y_a, y_b, exclude_ids)

    if trunk_collides?(preferred, bars_in_span) do
      preferred
      |> candidate_xs(bars_in_span)
      |> Enum.filter(valid_fn)
      |> Enum.find(fn x -> not trunk_collides?(x, bars_in_span) end)
      |> Kernel.||(preferred)
    else
      preferred
    end
  end

  # Styling is resolved per-path in `resolve_style/3`. See `build_path/3`
  # for how the category (normal / critical / invalid) is picked and how
  # per-connector overrides fall through onto the component defaults.
  # Everything else — color, stroke_width, dasharray, opacity, marker,
  # label fill — is driven by the path struct's fields. No other helpers.

  # -- Milestone detection (zero-duration event) --

  # A task is a milestone iff it has zero (fractional-day) duration — the SAME
  # test `bar_geometry/3` uses (`fe - fs <= 0`). Measuring in fractional days
  # (not date-truncated days) is essential since `:hour` zoom / sub-day
  # temporals exist: a 2-hour task starts and ends on the same DATE, so a
  # `Date.diff` test wrongly classified it as a milestone — the connector router
  # then applied milestone endpoint offsets + the 10px diamond gap while the bar
  # rendered as a thin bar, so arrows routed to/from a phantom diamond and
  # appeared disconnected. For pure-`Date` events this is identical to the old
  # `Date.diff` test (frac duration == date diff), so day/week/month is unchanged.
  defp milestone?(%PhoenixLiveGantt.Task{} = event) do
    ref = to_date(event.start)

    duration =
      frac_days(PhoenixLiveGantt.Task.effective_end(event), ref) - frac_days(event.start, ref)

    duration <= 0
  end

  # -- Grouping --

  defp build_groups(sorted_events) do
    sorted_events
    |> Enum.with_index()
    |> Enum.reduce(%{}, fn {event, idx}, acc ->
      group = get_group(event)
      Map.update(acc, group, idx, fn first_idx -> min(first_idx, idx) end)
    end)
  end

  defp show_group_header?(groups, event, idx) do
    group = get_group(event)
    group != nil and Map.get(groups, group) == idx
  end

  defp get_group(%PhoenixLiveGantt.Task{category: cat}) when not is_nil(cat), do: to_string(cat)

  defp get_group(%PhoenixLiveGantt.Task{extra: %{group: group}}) when not is_nil(group),
    do: to_string(group)

  defp get_group(_), do: nil

  # -- Range filtering --

  # Partition the event list by whether each event's `[start, end)` has any
  # overlap with the visible `date_range`. Returns a 3-tuple:
  #
  #   {in_range, earlier_count, later_count}
  #
  # Out-of-range events are dropped from all downstream rendering (no row,
  # no bar, no connector) but the counts are surfaced as edge indicators
  # so the user sees there's more data outside the window.
  # Validate event ids are unique across the entire input list. Duplicate
  # ids would produce duplicate DOM element ids (bar wrappers, popovers,
  # connector endpoints) and silently break: clicks would target whichever
  # `getElementById` returned first, arrows would attach to the wrong bar,
  # popover state would smear across the duplicates. Raise loudly at
  # render-time instead of debugging visual glitches later.
  defp validate_event_ids!(events) do
    {_seen, dups, nil_id?} =
      Enum.reduce(events, {MapSet.new(), MapSet.new(), false}, fn ev, {seen, dups, had_nil} ->
        cond do
          is_nil(ev.id) -> {seen, dups, true}
          MapSet.member?(seen, ev.id) -> {seen, MapSet.put(dups, ev.id), had_nil}
          true -> {MapSet.put(seen, ev.id), dups, had_nil}
        end
      end)

    if nil_id? do
      raise ArgumentError, """
      PhoenixLiveGantt.gantt/1: an event has a `nil` id. Every event needs a unique,
      non-nil `id` — connectors and `parent_id` reference it, and it forms the
      bar/popover DOM ids (so multiple `nil` ids silently collide).
      """
    end

    case MapSet.to_list(dups) do
      [] ->
        :ok

      ids ->
        raise ArgumentError, """
        PhoenixLiveGantt.gantt/1: duplicate event ids found in `events`: #{inspect(ids)}.

        Every event must have a unique `id`. Duplicate ids produce duplicate
        DOM element ids (bar wrappers, popovers, connector endpoints) which
        break click-targeting, arrow attachment and popover state.
        """
    end
  end

  defp partition_events_by_range(events, {origin, span_days} = _view) do
    Enum.reduce(events, {[], 0, 0}, fn event, {in_range, earlier, later} ->
      cond do
        # Drop events missing a start date entirely — without it there
        # is nothing to position the bar against. Silent (no Logger
        # call) so a malformed task can't spam the host app's logs.
        is_nil(event.start) or is_nil(PhoenixLiveGantt.Task.effective_end(event)) ->
          {in_range, earlier, later}

        true ->
          # Use the SAME fractional-day overlap (and the SAME origin/span) that
          # `bar_geometry/4` uses, so partition and bar rendering agree on what's
          # visible. They MUST: an event admitted here but clipped by
          # `bar_geometry` returns `%{out_of_range: true}` and the template
          # crashes on `bar.milestone`.
          fs = frac_days(event.start, origin)
          fe = frac_days(PhoenixLiveGantt.Task.effective_end(event), origin)
          is_milestone = fe - fs <= 0

          cond do
            not out_of_range_frac?(fs, fe, is_milestone, span_days) ->
              {[event | in_range], earlier, later}

            fs < 0 ->
              {in_range, earlier + 1, later}

            true ->
              {in_range, earlier, later + 1}
          end
      end
    end)
    |> then(fn {in_range, e, l} -> {Enum.reverse(in_range), e, l} end)
  end

  # -- Layout ordering --

  # Sort events to minimize arrow crossings. Within each group, events are
  # placed in a modified topological order: start-date ordered, but whenever
  # an event is placed, any of its direct dependents whose other prerequisites
  # are already placed get placed immediately after it (adjacent). Users can
  # override the computed position by setting `extra.order` (integer) on
  # specific events.
  defp sort_events_for_layout(events, connectors) do
    auto_positions = compute_auto_positions(events, connectors)

    Enum.sort_by(events, fn e ->
      group = get_group(e)
      position = explicit_order(e) || Map.get(auto_positions, e.id, 0)
      {group || "", position, to_string(e.id)}
    end)
  end

  defp explicit_order(%PhoenixLiveGantt.Task{extra: %{order: order}}) when is_integer(order),
    do: order

  defp explicit_order(_), do: nil

  # Compute an integer placement index for each event within its group.
  # Events with no in-group dependencies are placed by start_date. Direct
  # dependents get placed right after their source when possible.
  defp compute_auto_positions(events, connectors) do
    events
    |> Enum.group_by(&get_group/1)
    |> Enum.flat_map(fn {_group, group_events} ->
      group_events
      |> auto_place_group(connectors)
      |> Enum.with_index()
      |> Enum.map(fn {event, idx} -> {event.id, idx} end)
    end)
    |> Map.new()
  end

  defp auto_place_group(group_events, all_connectors) do
    group_ids = MapSet.new(group_events, & &1.id)

    in_group_edges =
      Enum.filter(all_connectors, fn c ->
        MapSet.member?(group_ids, c.from) and MapSet.member?(group_ids, c.to)
      end)

    # `deps_by_source` keeps `{to_id, critical?}` tuples (not just ids) so
    # `place_dependents/5` can sort dependents critical-first. This lets the
    # critical-path chain land on adjacent rows even when a parallel branch
    # has an earlier start date.
    deps_by_source =
      Enum.group_by(in_group_edges, & &1.from, &{&1.to, Map.get(&1, :critical, false)})

    preds_by_target = Enum.group_by(in_group_edges, & &1.to, & &1.from)
    events_by_id = Map.new(group_events, &{&1.id, &1})

    # IMPORTANT: pass `Date` as the third arg so `Date.compare/2` is used.
    # Default term ordering on `Date` structs compares by struct keys
    # alphabetically (`:day` first), so without this `~D[2026-07-05]` would
    # sort before `~D[2026-05-14]` because day-of-month 5 < 14.
    sorted_by_date = Enum.sort_by(group_events, &to_date(&1.start), Date)

    {placed, _placed_set} =
      Enum.reduce(sorted_by_date, {[], MapSet.new()}, fn event, {placed, placed_set} ->
        if MapSet.member?(placed_set, event.id) do
          {placed, placed_set}
        else
          # Place this event
          placed = [event | placed]
          placed_set = MapSet.put(placed_set, event.id)

          # Immediately after, try to place each direct dependent whose other
          # predecessors are also already placed.
          place_dependents(
            event,
            events_by_id,
            deps_by_source,
            preds_by_target,
            {placed, placed_set}
          )
        end
      end)

    Enum.reverse(placed)
  end

  # Recursively place direct dependents (and their dependents) adjacent to
  # source when their prerequisites are met. Sort key is
  # `{not critical?, start_date}` — so when a source has both a critical-path
  # dependent and a parallel-branch dependent, the critical one is placed
  # first regardless of which has the earlier start date. This keeps the
  # critical chain on adjacent rows for visual continuity.
  defp place_dependents(
         event,
         events_by_id,
         deps_by_source,
         preds_by_target,
         {placed, placed_set}
       ) do
    dependent_specs = Map.get(deps_by_source, event.id, [])

    dependent_events =
      dependent_specs
      |> Enum.map(fn {dep_id, critical?} ->
        case Map.get(events_by_id, dep_id) do
          nil -> nil
          event -> {event, critical?}
        end
      end)
      |> Enum.filter(& &1)
      # Sort key includes a Date in a tuple, so we can't pass `Date` as the
      # third arg. Convert to gregorian-days int so the tuple compare works
      # correctly (otherwise default Date term ordering compares :day first).
      |> Enum.sort_by(fn {event, critical?} ->
        {not critical?, Date.to_gregorian_days(to_date(event.start))}
      end)

    Enum.reduce(dependent_events, {placed, placed_set}, fn {dep, _critical?}, {p, s} ->
      cond do
        MapSet.member?(s, dep.id) ->
          {p, s}

        not all_predecessors_placed?(dep, preds_by_target, s) ->
          {p, s}

        true ->
          # Place dependent, then recurse on its dependents
          new_placed = [dep | p]
          new_set = MapSet.put(s, dep.id)

          place_dependents(
            dep,
            events_by_id,
            deps_by_source,
            preds_by_target,
            {new_placed, new_set}
          )
      end
    end)
  end

  defp all_predecessors_placed?(event, preds_by_target, placed_set) do
    preds = Map.get(preds_by_target, event.id, [])
    Enum.all?(preds, &MapSet.member?(placed_set, &1))
  end

  # -- Progress --

  # Struct field wins; `extra.progress_pct` is the fallback for consumers that
  # carry their data in `extra`. The struct default is `nil`, so an unset field
  # transparently defers to `extra`.
  defp progress_pct(%PhoenixLiveGantt.Task{progress_pct: pct}) when is_number(pct), do: pct

  defp progress_pct(%PhoenixLiveGantt.Task{extra: %{progress_pct: pct}}) when is_number(pct),
    do: pct

  defp progress_pct(_), do: 0

  defp assignee(%PhoenixLiveGantt.Task{assignee: a}) when is_binary(a), do: a
  defp assignee(%PhoenixLiveGantt.Task{extra: %{assignee: a}}) when is_binary(a), do: a
  defp assignee(_), do: nil

  # -- Sub-project tree helpers --
  #
  # An event becomes a sub-project (a roll-up container) by carrying
  # `extra.parent_id => "<some-other-event-id>"`. Multiple events can
  # share the same parent_id (siblings in a sub-project), and a
  # sub-project event can itself have a parent_id (recursion is
  # unbounded). Events whose parent_id points to an event that isn't
  # in the list are treated as top-level.

  defp parent_id_of(%PhoenixLiveGantt.Task{extra: %{parent_id: pid}})
       when is_binary(pid) or is_atom(pid),
       do: to_string(pid)

  defp parent_id_of(_), do: nil

  # Walk the event list once, returning a parent_id → [child_ids] map
  # plus a reverse child_id → parent_id map. Both keep entries only
  # for parents that actually exist in the list.
  defp build_event_tree(events) do
    by_id = Map.new(events, &{&1.id, &1})

    {children, parents} =
      Enum.reduce(events, {%{}, %{}}, fn ev, {ch, pa} ->
        case parent_id_of(ev) do
          nil ->
            {ch, pa}

          pid ->
            cond do
              not Map.has_key?(by_id, pid) -> {ch, pa}
              # Reject self-reference and any chain that would close a cycle
              # (parent's existing ancestor chain already contains the child).
              pid == ev.id -> {ch, pa}
              cycle?(pid, ev.id, pa) -> {ch, pa}
              true -> {Map.update(ch, pid, [ev.id], &[ev.id | &1]), Map.put(pa, ev.id, pid)}
            end
        end
      end)

    # Reverse so children stay in original (sorted) order
    children = Map.new(children, fn {k, v} -> {k, Enum.reverse(v)} end)

    %{by_id: by_id, children: children, parents: parents}
  end

  # True if walking up `start`'s parent chain reaches `target`. Used at
  # tree-build time to refuse any new parent_id link that would close a
  # cycle — without this guard, `ancestor_ids/2`, `effective_id/3`, and
  # `descendants_of/2` would all recurse forever and hang the render.
  defp cycle?(start, target, parents) do
    cycle_walk?(start, target, parents, %{})
  end

  defp cycle_walk?(id, target, _parents, _seen) when id == target, do: true

  defp cycle_walk?(id, target, parents, seen) do
    cond do
      Map.has_key?(seen, id) ->
        false

      true ->
        case Map.get(parents, id) do
          nil -> false
          pid -> cycle_walk?(pid, target, parents, Map.put(seen, id, true))
        end
    end
  end

  # True if the event has at least one child in the same event list.
  defp sub_project?(event, tree), do: Map.has_key?(tree.children, event.id)

  # Walk up the parent chain. Result includes the event's own id at
  # the head and the eventual top-level ancestor at the tail.
  defp ancestor_ids(id, tree) do
    case Map.get(tree.parents, id) do
      nil -> [id]
      pid -> [id | ancestor_ids(pid, tree)]
    end
  end

  # Depth from root — 0 for top-level, 1 for first nested, etc.
  defp depth_of(id, tree), do: length(ancestor_ids(id, tree)) - 1

  # The id that should actually be rendered for `id` given which
  # sub-projects are expanded. Walks UP the parent chain — for each
  # ancestor that is NOT expanded, the visible id is that ancestor
  # (the roll-up bar). Returns `id` itself if all ancestors are
  # expanded or it has no parents.
  defp effective_id(id, tree, expanded) do
    case Map.get(tree.parents, id) do
      nil ->
        id

      pid ->
        case effective_id(pid, tree, expanded) do
          ^pid -> if MapSet.member?(expanded, pid), do: id, else: pid
          # An ancestor higher up is collapsed — that ancestor wins.
          higher -> higher
        end
    end
  end

  # True if the event sits INSIDE an expanded sub-project — i.e. one of
  # its ancestors is expanded. The sub-project parent itself returns
  # false; we want the tint to apply only to the children that the
  # parent visually contains.
  defp in_open_subproject?(event, tree, expanded) do
    event.id
    |> ancestor_ids(tree)
    |> tl()
    |> Enum.any?(&MapSet.member?(expanded, &1))
  end

  # Pick a frame color by nesting depth. `parent_depth` is the depth
  # of the sub-project that the row/frame belongs to (top-level
  # sub-project = 0, sub-project inside one = 1, etc.). Cycles
  # through the list once depth exceeds list length so deeper
  # nesting still gets a stable color. Passing a single string skips
  # the per-depth logic and always returns that same color.
  defp frame_color_for(colors, parent_depth) when is_list(colors) and colors != [] do
    Enum.at(colors, rem(parent_depth, length(colors)))
  end

  defp frame_color_for(color, _parent_depth) when is_binary(color), do: color
  defp frame_color_for(_, _), do: "color-mix(in oklab, var(--color-base-content) 8%, transparent)"

  # Events that should be visible given the current expanded set: any
  # event whose parents are ALL expanded (or that has no parents).
  defp visible_events(events, tree, expanded) do
    Enum.filter(events, fn ev ->
      ev.id
      |> ancestor_ids(tree)
      |> tl()
      |> Enum.all?(&MapSet.member?(expanded, &1))
    end)
  end

  # Recursively collect every descendant id of `id` in the tree.
  defp descendants_of(id, tree) do
    case Map.get(tree.children, id) do
      nil ->
        []

      child_ids ->
        child_ids ++ Enum.flat_map(child_ids, &descendants_of(&1, tree))
    end
  end

  # Convert the consumer-provided `expanded` attr (nil, list, or
  # MapSet) into a MapSet for fast membership checks. nil → empty
  # (all sub-projects collapsed by default).
  # `expanded` accepts:
  #   * `nil` or `[]` → nothing expanded
  #   * a `MapSet` or list of event ids → exactly those expanded
  #   * `:all` → every event in the input is expanded (callers want
  #     "show everything" without listing ids; expand to a concrete
  #     set so all downstream `MapSet.member?` checks stay branchless)
  defp normalize_expanded(nil, _events), do: MapSet.new()
  defp normalize_expanded(:all, events), do: MapSet.new(events, & &1.id)
  defp normalize_expanded(%MapSet{} = set, _events), do: set
  defp normalize_expanded(list, _events) when is_list(list), do: MapSet.new(list)
  defp normalize_expanded(_, _events), do: MapSet.new()

  # For every sub-project event that doesn't carry its own start/end,
  # synthesize them from the min/max of its leaf descendants' dates.
  # Events with explicit dates are left untouched so consumers can
  # override the auto-rollup when they want a specific range.
  defp rollup_subproject_dates(events, tree) do
    by_id = tree.by_id

    Enum.map(events, fn ev ->
      cond do
        not sub_project?(ev, tree) ->
          ev

        # Raw `start` AND `end` set — consumer wants this parent to
        # span their explicit dates, not the children's range.
        ev.start && ev.end ->
          ev

        true ->
          ev.id
          |> descendants_of(tree)
          |> Enum.map(&Map.get(by_id, &1))
          |> Enum.reject(&is_nil/1)
          |> rolled_up_range()
          |> case do
            nil ->
              ev

            # Map-update (not `%Task{ev | ...}`) preserves the struct while
            # keeping dialyzer happy: `ev` is a generic `Enum.map` binding, so
            # its success typing isn't narrowed to `Task` and a *named* struct
            # update trips a (harmless) "expected a struct" success-typing note.
            {min_start, max_end} ->
              %{ev | start: min_start, end: max_end}
          end
      end
    end)
  end

  defp rolled_up_range([]), do: nil

  defp rolled_up_range(events) do
    # Roll up in the children's NATIVE temporal type — do NOT truncate to dates.
    # Truncating collapsed a parent of sub-day children (e.g. 10:00–14:00) to
    # start == end on one date, which `bar_geometry/4` then drew as a midnight
    # milestone diamond while the children sat at their real hours. Comparing via
    # `to_naive_dt/1` keeps mixed Date/NaiveDateTime/DateTime children orderable
    # while returning the original (untruncated) endpoints.
    starts = events |> Enum.map(& &1.start) |> Enum.reject(&is_nil/1)

    ends =
      events
      |> Enum.map(&PhoenixLiveGantt.Task.effective_end/1)
      |> Enum.reject(&is_nil/1)

    case {starts, ends} do
      {[], _} ->
        nil

      {_, []} ->
        nil

      {ss, es} ->
        {Enum.min_by(ss, &to_naive_dt/1, NaiveDateTime),
         Enum.max_by(es, &to_naive_dt/1, NaiveDateTime)}
    end
  end

  # Re-order an already-sorted event list so children appear directly
  # after their parent (recursively). Top-level events keep their
  # sorter-derived order; for each one, its descendants get spliced
  # in immediately, in their original sorter order. This makes the
  # expanded sub-project read as a contiguous group instead of having
  # the children scatter to wherever date-sort would put them.
  defp cluster_subprojects(events, tree) do
    by_id = Map.new(events, &{&1.id, &1})

    # Top-level (within the visible set) = no `tree.parents` entry,
    # i.e. the tree-builder didn't accept a parent link for this
    # event. Consulting the tree (rather than re-reading
    # `parent_id_of/1` directly) means cycle-closing or unresolved
    # parent_ids that the builder rejected don't accidentally remove
    # the event from the top-level set here.
    top_level =
      Enum.filter(events, fn ev ->
        not Map.has_key?(tree.parents, ev.id)
      end)

    # Indices of children inside `events`, so we can recover the
    # sorter's relative order between siblings.
    order_idx = events |> Enum.with_index() |> Map.new(fn {ev, i} -> {ev.id, i} end)

    Enum.flat_map(top_level, &expand_with_children(&1, by_id, tree, order_idx))
  end

  # For every currently-expanded sub-project, compute a rectangle in
  # timeline coordinates that brackets all of its visible descendants:
  # x-range from the sub-project's rolled-up date range, y-range from
  # the topmost-to-bottommost descendant row. Used by the renderer to
  # draw a translucent frame behind the children.
  defp compute_subproject_frames(
         sorted_events,
         tree,
         expanded,
         row_positions,
         row_px,
         view,
         day_px,
         min_bar_px
       ) do
    by_id = Map.new(sorted_events, &{&1.id, &1})

    sorted_events
    |> Enum.filter(fn ev ->
      sub_project?(ev, tree) and MapSet.member?(expanded, ev.id)
    end)
    |> Enum.flat_map(fn parent ->
      descendants =
        parent.id
        |> descendants_of(tree)
        |> Enum.map(&Map.get(by_id, &1))
        |> Enum.reject(&is_nil/1)

      # Only descendants — the frame brackets the children, NOT the
      # sub-project's own roll-up row. (The roll-up bar itself stays
      # visually distinct via `bar_subproject_class`.)
      tops =
        descendants
        |> Enum.map(fn ev -> get_in(row_positions.positions, [ev.id, :top]) end)
        |> Enum.reject(&is_nil/1)

      case tops do
        [] ->
          []

        _ ->
          bar = bar_geometry(parent, view, day_px, min_bar_px)

          if Map.get(bar, :out_of_range) do
            []
          else
            # Pull the top up by the bar's 4px bottom inset so the
            # frame visually touches the sub-project's bar instead of
            # leaving a thin row-padding gap between them.
            [
              %{
                left_px: bar.left_px,
                right_px: bar.left_px + max(bar.width_px, 4),
                top_y: Enum.min(tops) - 4,
                bottom_y: Enum.max(tops) + row_px,
                parent_depth: depth_of(parent.id, tree)
              }
            ]
          end
      end
    end)
  end

  defp expand_with_children(event, by_id, tree, order_idx) do
    children =
      tree.children
      |> Map.get(event.id, [])
      |> Enum.filter(&Map.has_key?(by_id, &1))
      |> Enum.sort_by(&Map.get(order_idx, &1, 0))
      |> Enum.map(&Map.get(by_id, &1))
      |> Enum.flat_map(&expand_with_children(&1, by_id, tree, order_idx))

    [event | children]
  end

  # Walk each connector's endpoints up the parent chain to the nearest
  # visible ancestor. Connectors that collapse to the same effective
  # endpoint (both inside the same collapsed sub-project) get dropped
  # entirely — there's nothing to draw.
  defp retarget_connectors(connectors, tree, expanded) do
    connectors
    |> Enum.map(fn c ->
      from = effective_id(c.from, tree, expanded)
      to = effective_id(c.to, tree, expanded)
      %{c | from: from, to: to}
    end)
    |> Enum.reject(fn c -> c.from == c.to end)
  end

  # Subtitle for the popover — assignee and/or progress when relevant.
  # Returns nil (caller skips the row) when neither applies.
  #
  #   "Alice • 80%"   — both
  #   "Alice"         — assignee only
  #   "80%"           — progress only
  defp bar_subtitle(event) do
    parts =
      []
      |> maybe_append(assignee(event))
      |> maybe_append(progress_label(event))
      |> Enum.reverse()

    case parts do
      [] -> nil
      _ -> Enum.join(parts, " \u2022 ")
    end
  end

  defp maybe_append(list, nil), do: list
  defp maybe_append(list, ""), do: list
  defp maybe_append(list, val), do: [val | list]

  defp progress_label(event) do
    case progress_pct(event) do
      pct when is_number(pct) and pct > 0 -> "#{round(pct)}%"
      _ -> nil
    end
  end

  # -- Badges --

  # `event.extra.badges` is a list of badge maps. Anything else
  # (missing key, non-list, non-map entries) is silently dropped so a
  # typo can't crash the render.
  defp bar_badges(%PhoenixLiveGantt.Task{extra: %{badges: badges}}) when is_list(badges),
    do: Enum.filter(badges, &is_map/1)

  defp bar_badges(_), do: []

  # Walk badges in declaration order, count how many we've already
  # seen at each corner, and tag each badge with its per-corner index.
  # Index 0 sits at the corner; index 1 is one badge-width away; etc.
  # Defaults to `:top_right` so plain `%{content: "..."}` maps stack
  # at the standard notification corner instead of all piling onto
  # the same spot.
  defp bar_badges_with_offsets(event) do
    {tagged, _counts} =
      event
      |> bar_badges()
      |> Enum.reduce({[], %{}}, fn badge, {acc, counts} ->
        corner = badge[:corner] || :top_right
        index = Map.get(counts, corner, 0)
        {[{badge, index} | acc], Map.put(counts, corner, index + 1)}
      end)

    Enum.reverse(tagged)
  end

  # Action badges: `:badge` (single map) or `:badges` (list). Both are
  # accepted so callers don't have to wrap a single badge in a list.
  defp action_badges(%{badges: badges}) when is_list(badges),
    do: Enum.filter(badges, &is_map/1)

  defp action_badges(%{badge: badge}) when is_map(badge), do: [badge]
  defp action_badges(_), do: []

  # Bar badge position style — pixel coords. The badge is positioned
  # inside the same row container as the bar, so coords use the bar's
  # left/right (x) and the row's top/bottom (y, which is row_top..
  # row_top+row_px). The bar itself has a 4px inset (top-1/bottom-1)
  # from the row, so badges anchor to the bar's visual corner not the
  # row's edge.
  defp badge_position_style(corner, bar, row_px, corner_index, content_width) do
    {x_anchor, y_anchor} = badge_anchor(corner, bar, row_px, corner_index, content_width)
    "#{x_anchor}; #{y_anchor}"
  end

  # Horizontal overhang only — badges stick PAST the bar's left/right
  # edge into the row's empty side-space, but stay fully WITHIN the
  # row's vertical bounds (top: 0 → top: row_px - 16). Each successive
  # badge in the same corner shifts INWARD by `@badge_stack_step_px`
  # so multiple badges in one corner sit side by side instead of on
  # top of each other.
  @badge_overhang_px 10
  @badge_size_px 16
  @badge_stack_step_px 18

  # Badges anchor to a bar corner (a % position) plus a fixed px overhang /
  # stack offset → `calc(P% + Npx)`, so they track the bar at any fill width
  # while keeping their constant pixel overhang.
  defp badge_anchor(:top_left, bar, _, idx, cw),
    do:
      {badge_left(bar.left_px, -@badge_overhang_px + idx * @badge_stack_step_px, cw), "top: 0px"}

  defp badge_anchor(:bottom_left, bar, row_px, idx, cw),
    do:
      {badge_left(bar.left_px, -@badge_overhang_px + idx * @badge_stack_step_px, cw),
       "top: #{row_px - @badge_size_px}px"}

  defp badge_anchor(:bottom_right, bar, row_px, idx, cw),
    do:
      {badge_left(
         bar_right_px(bar),
         -@badge_size_px + @badge_overhang_px - idx * @badge_stack_step_px,
         cw
       ), "top: #{row_px - @badge_size_px}px"}

  # Default = top_right.
  defp badge_anchor(_, bar, _, idx, cw),
    do:
      {badge_left(
         bar_right_px(bar),
         -@badge_size_px + @badge_overhang_px - idx * @badge_stack_step_px,
         cw
       ), "top: 0px"}

  defp badge_left(anchor_px, offset_px, content_width),
    do: "left: calc(#{pct(anchor_px, content_width)}% + #{offset_px}px)"

  # Milestones have width 0 — render right-of-center.
  defp bar_right_px(%{left_px: l, width_px: w}), do: l + w
  defp bar_right_px(%{left_px: l}), do: l

  # Action button badge: corner classes (button is `relative` so these
  # anchor to the button's box).
  defp action_badge_corner_class(:top_left), do: "absolute -top-1.5 -left-1.5"
  defp action_badge_corner_class(:bottom_left), do: "absolute -bottom-1.5 -left-1.5"
  defp action_badge_corner_class(:bottom_right), do: "absolute -bottom-1.5 -right-1.5"
  defp action_badge_corner_class(_), do: "absolute -top-1.5 -right-1.5"

  # Same shape as the component's @badge_class default — used as a
  # safe fallback when the badge is rendered outside of a place that
  # threads `class` through (none today, but future-proofs).
  defp badge_default_class do
    "inline-flex items-center justify-center px-1.5 min-w-[1.25rem] h-5 text-[0.65rem] font-bold rounded-full ring-2 ring-base-100 leading-none pointer-events-none"
  end

  # -- Bar popover / actions --

  # Per-event action buttons shown in the bar popover. Source:
  # `event.extra.actions` — a list of maps. Anything other than a list
  # is silently ignored (so consumers can't accidentally crash the
  # render with a typo). Each action map shape:
  #
  #   %{
  #     icon:       "hero-chat-bubble-left",  # required, CSS class on <span>
  #     tooltip:    "Open comments",          # optional, becomes `title` attr
  #     phx_click:  "open_comments",          # optional, becomes phx-click
  #     phx_value:  %{event_id: "..."},       # optional, expanded to phx-value-*
  #     phx_target: "#sidebar",               # optional, phx-target
  #     href:       "/events/123",            # optional, renders as <a>
  #     class:      "text-primary"            # optional, extra classes
  #   }
  defp bar_actions(%PhoenixLiveGantt.Task{extra: %{actions: actions}}) when is_list(actions),
    do: Enum.filter(actions, &is_map/1)

  defp bar_actions(_), do: []

  # Action list for the popover, with an expand/collapse button
  # prepended when the event is a sub-project that has an
  # `on_toggle_expand` handler wired. The pseudo-action reuses the
  # same `bar_action_button` rendering, so it picks up the existing
  # `phx-value-event-id` plumbing and tooltip styling for free.
  defp popover_actions(event, tree, expanded, on_toggle, translations) do
    base = bar_actions(event)

    if sub_project?(event, tree) and on_toggle do
      expanded? = MapSet.member?(expanded, event.id)

      toggle_action = %{
        id: "_subproject_toggle",
        icon: if(expanded?, do: "hero-minus-mini", else: "hero-plus-mini"),
        tooltip:
          if(expanded?,
            do: I18n.label(:collapse_subproject, translations),
            else: I18n.label(:expand_subproject, translations)
          ),
        phx_click: on_toggle
      }

      [toggle_action | base]
    else
      base
    end
  end

  # DOM ids for hook targeting. `id_prefix` falls back to `"wf"` when
  # the component doesn't get an explicit `id`; multiple un-id'd
  # waterfalls on one page would collide and is unsupported anyway.
  defp bar_dom_id(id_prefix, event_id),
    do: "#{id_prefix || "lg"}-bar-#{event_id}"

  defp popover_dom_id(id_prefix, event_id),
    do: "#{id_prefix || "lg"}-bar-popover-#{event_id}"

  defp label_dom_id(id_prefix, event_id),
    do: "#{id_prefix || "lg"}-label-#{event_id}"

  defp label_popover_dom_id(id_prefix, event_id),
    do: "#{id_prefix || "lg"}-label-popover-#{event_id}"

  # Anchor the label popover to the row's exact y. Uses the
  # row_positions map computed once per render, so any group-header
  # offsets are accounted for automatically. Pad 4px so the popover's
  # top edge sits where the label content begins (matches the bar's
  # top-1 visual inset).
  defp label_popover_style(row_positions, event_id, row_px) do
    case row_positions.positions do
      %{^event_id => %{top: top}} ->
        "top: #{top + popover_top_inset()}px; min-height: #{row_px - 2 * popover_top_inset()}px"

      _ ->
        "top: 0px; min-height: #{row_px - 2 * popover_top_inset()}px"
    end
  end

  # Popover anchors to the bar's exact rectangle: same `left`, same
  # `top` (row_top + 4 to match the bar's `top-1` inset), and at least
  # as wide as the bar so it visually grows downward (and rightward
  # when the title needs more room) rather than floating separately.
  defp popover_style(bar, row_px, content_width) do
    "left: #{pct(bar.left_px, content_width)}%; " <>
      "top: #{popover_top_inset()}px; " <>
      "min-width: #{pct(bar.width_px, content_width)}%; " <>
      "min-height: #{row_px - 2 * popover_top_inset()}px"
  end

  # 4px = Tailwind `top-1` on the bar. Keep the popover's top edge in
  # the same spot so it looks like the bar expanded.
  defp popover_top_inset, do: 4

  # Renders a single action — <a href> if `:href` is set, otherwise a
  # <button> wired with phx-click / phx-value-*. Stops click propagation
  # so clicking an action doesn't also fire the bar's on_event_click.
  attr :action, :map, required: true
  attr :event_id, :string, required: true
  attr :class, :string, default: nil
  attr :disabled_class, :string, default: nil

  attr :badge_class, :string, default: nil
  attr :badge_default_color, :string, default: "bg-error"

  defp bar_action_button(assigns) do
    assigns = assign(assigns, :disabled?, !!assigns.action[:disabled])

    ~H"""
    <%= cond do %>
      <% @disabled? -> %>
        <%!-- Disabled: render as an unclickable <span>. Drops phx-click
           + href entirely so neither the browser nor LiveView fire
           anything. aria-disabled exposes state to assistive tech. --%>
        <span
          class={[
            "lg-bar-action",
            @class,
            @disabled_class,
            @action[:class]
          ]}
          title={@action[:tooltip]}
          data-action-id={@action[:id]}
          aria-disabled="true"
          role="button"
        >
          <span :if={@action[:icon]} class={@action[:icon]}></span>
          <span :if={@action[:label]}>{@action[:label]}</span>
          <.action_badge
            :for={badge <- action_badges(@action)}
            badge={badge}
            class={@badge_class}
            default_color={@badge_default_color}
          />
        </span>
      <% href = @action[:href] -> %>
        <a
          href={href}
          class={["lg-bar-action", @class, @action[:class]]}
          title={@action[:tooltip]}
          data-action-id={@action[:id]}
          phx-click={@action[:phx_click]}
          phx-target={@action[:phx_target]}
          {phx_value_attrs(@action[:phx_value], @event_id)}
        >
          <span :if={@action[:icon]} class={@action[:icon]}></span>
          <span :if={@action[:label]}>{@action[:label]}</span>
          <.action_badge
            :for={badge <- action_badges(@action)}
            badge={badge}
            class={@badge_class}
            default_color={@badge_default_color}
          />
        </a>
      <% true -> %>
        <button
          type="button"
          class={["lg-bar-action", @class, @action[:class]]}
          title={@action[:tooltip]}
          data-action-id={@action[:id]}
          phx-click={@action[:phx_click]}
          phx-target={@action[:phx_target]}
          {phx_value_attrs(@action[:phx_value], @event_id)}
        >
          <span :if={@action[:icon]} class={@action[:icon]}></span>
          <span :if={@action[:label]}>{@action[:label]}</span>
          <.action_badge
            :for={badge <- action_badges(@action)}
            badge={badge}
            class={@badge_class}
            default_color={@badge_default_color}
          />
        </button>
    <% end %>
    """
  end

  # Bar-level badge — sibling of the bar, positioned in pixels against
  # the bar's rectangle (bar.left_px / right_px / row top+row_px). The
  # ~50% offset (-10px = roughly half a 20px pill) gives the badge the
  # familiar "overhanging the corner" look.
  attr :badge, :map, required: true
  attr :corner_index, :integer, required: true
  attr :bar, :map, required: true
  attr :row_px, :integer, required: true
  attr :content_width, :integer, required: true
  attr :event_id, :string, required: true
  attr :class, :string, required: true
  attr :default_color, :string, required: true

  defp bar_badge(assigns) do
    assigns = assign(assigns, :corner, assigns.badge[:corner] || :top_right)

    ~H"""
    <span
      class={[
        "lg-bar-badge",
        @class,
        @badge[:color] || @default_color,
        @badge[:text_color] || Safe.infer_text_color(@badge[:color]),
        @badge[:flash] && "animate-pulse",
        @badge[:class]
      ]}
      style={badge_position_style(@corner, @bar, @row_px, @corner_index, @content_width)}
      data-event-id={@event_id}
      data-badge-corner={@corner}
      data-row-px={@row_px}
    >
      {@badge[:content]}
    </span>
    """
  end

  # Action-button badge — child of the button (the button is `relative`),
  # positioned with negative inset so the badge overhangs the corner.
  attr :badge, :map, required: true
  attr :class, :string, default: nil
  attr :default_color, :string, default: "bg-error"

  defp action_badge(assigns) do
    ~H"""
    <span class={[
      "lg-action-badge",
      @class || badge_default_class(),
      action_badge_corner_class(@badge[:corner]),
      @badge[:color] || @default_color,
      @badge[:text_color] || Safe.infer_text_color(@badge[:color]),
      @badge[:flash] && "animate-pulse",
      @badge[:class]
    ]}>
      {@badge[:content]}
    </span>
    """
  end

  # Expand a values map to phx-value-* keyword pairs, defaulting to
  # event_id when no override is provided so consumers don't have to
  # repeat it in every action.
  defp phx_value_attrs(nil, event_id), do: [{:"phx-value-event-id", event_id}]

  # The event id is ALWAYS exposed as `phx-value-event-id` (hyphen), matching
  # the no-value path and the chevron's `on_toggle_expand` — so a handler reads
  # `%{"event-id" => id}` regardless of whether extra `phx_value` keys were set.
  # Any keys the action supplies are emitted alongside as `phx-value-<key>`.
  defp phx_value_attrs(%{} = values, event_id) do
    extra = for {k, v} <- values, k not in [:event_id, "event-id"], do: {:"phx-value-#{k}", v}
    [{:"phx-value-event-id", event_id} | extra]
  end

  # Status/progress styling is now driven by component attrs applied
  # inline in the template — see `bar_class` / `status_*_class` /
  # `progress_*_class`. Consumers customize via those attrs rather than
  # by patching helper functions.

  # -- Today marker helpers --

  # A bare `Date` today has no time-of-day, so it represents the whole DAY: it's
  # in range when that day OVERLAPS the window, not just when its midnight does.
  # This matters under a sub-day `window_start` (a NaiveDateTime origin), where a
  # Date today's midnight sits before the intra-day origin (`fd < 0`) even though
  # the day's hours fill the window — without this the today line hides and a
  # spurious "← Today" edge pill renders. A precise NaiveDateTime/DateTime today
  # is a single instant, so the point test is correct for it.
  defp today_in_range?(%Date{} = today, {origin, span_days}) do
    fd = frac_days(today, origin)
    fd > -1 and fd < span_days
  end

  defp today_in_range?(today, {origin, span_days}) do
    fd = frac_days(today, origin)
    fd >= 0 and fd < span_days
  end

  # Which edge today sits past, or nil when it's on-screen. Drives the
  # off-screen "Today" directional hint (so the axis needn't stretch to reach
  # a far-away today). Mirrors `today_in_range?`'s Date-is-a-whole-day rule.
  defp today_offscreen_side(%Date{} = today, {origin, span_days}) do
    fd = frac_days(today, origin)

    cond do
      fd <= -1 -> :before
      fd >= span_days -> :after
      true -> nil
    end
  end

  defp today_offscreen_side(today, {origin, span_days}) do
    fd = frac_days(today, origin)

    cond do
      fd < 0 -> :before
      fd >= span_days -> :after
      true -> nil
    end
  end

  # A `Date` today has no time-of-day, so center the marker in its day; a
  # `DateTime`/`NaiveDateTime` "now" lands at its exact position (precise at
  # hour zoom).
  defp today_left_px(%Date{} = today, {origin, span_days}, day_px) do
    noon = x_px(today, origin, day_px) + div(day_px, 2)
    # `today_in_range?` admits a `Date` whenever its whole day overlaps the window
    # (`fd > -1`), but in a sub-day window the noon anchor can land outside the
    # visible span — drawing the marker off-screen with no directional pill. Clamp
    # it to the window's near edge so an overlapping day always shows a marker.
    lo = @axis_pad_px
    hi = @axis_pad_px + round(span_days * day_px)
    noon |> max(lo) |> min(hi)
  end

  defp today_left_px(today, {origin, _span}, day_px), do: x_px(today, origin, day_px)

  # -- Non-working dates --

  defp non_working_dates(day_markers) do
    day_markers
    |> Enum.filter(fn m -> not m.available end)
    |> Enum.flat_map(fn marker ->
      end_date = Map.get(marker, :end_date) || Date.add(marker.start_date, 1)

      Date.range(marker.start_date, Date.add(end_date, -1))
      |> Enum.to_list()
    end)
    |> MapSet.new()
  end

  # -- Bar tooltip --

  defp bar_title(event) do
    parts = [event.title]

    parts =
      if progress_pct(event) > 0,
        do: parts ++ ["#{round(progress_pct(event))}%"],
        else: parts

    parts =
      if assignee(event),
        do: parts ++ [assignee(event)],
        else: parts

    Enum.join(parts, " — ")
  end

  # -- Toolbar helpers --

  # Today button click handler. Priority:
  #   1. `on_scroll_today` callback — consumer-supplied JS/event name
  #   2. fall back to `JS.dispatch("lg:scroll-today", to: "##{id}")`
  #      consumed by the LgAutoScroll hook
  # Returns nil when neither is available; the button is rendered disabled
  # in that case.
  defp today_click_handler(_id, handler) when not is_nil(handler), do: handler

  defp today_click_handler(id, nil) when is_binary(id) do
    JS.dispatch("lg:scroll-today", to: "##{id}")
  end

  defp today_click_handler(_, _), do: nil

  # The today button can actually scroll iff a custom `on_scroll_today` is
  # given, OR the default `lg:scroll-today` dispatch has a listener — which
  # requires both an `id` (the dispatch target) and `enable_hooks` (so the
  # `LgAutoScroll` hook is attached to that id). Otherwise it's rendered
  # disabled rather than silently doing nothing.
  defp today_button_functional?(on_scroll_today, _id, _enable_hooks)
       when not is_nil(on_scroll_today),
       do: true

  defp today_button_functional?(_on_scroll_today, id, enable_hooks),
    do: is_binary(id) and enable_hooks == true

  defp zoom_label(:min5, t), do: I18n.label(:min5, t)
  defp zoom_label(:min15, t), do: I18n.label(:min15, t)
  defp zoom_label(:hour, t), do: I18n.label(:hour, t)
  defp zoom_label(:day, t), do: I18n.label(:day, t)
  defp zoom_label(:week, t), do: I18n.label(:week, t)
  defp zoom_label(:month, t), do: I18n.label(:month, t)
  defp zoom_label(other, _t), do: other |> to_string() |> String.capitalize()

  # Offset so edge indicators sit BELOW the column-header row (not on top
  # of the day numbers). The numbers are empirical — toolbar height ≈ 44px
  # at btn-xs + padding; column header ≈ 32px; plus a ~6px visual gap so
  # the pill doesn't butt right up against the header border.
  defp edge_indicator_top_px(true), do: 86
  defp edge_indicator_top_px(false), do: 42

  # -- Date helpers --

  defp to_date(%Date{} = d), do: d
  defp to_date(%DateTime{} = dt), do: DateTime.to_date(dt)
  defp to_date(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_date(ndt)
  defp to_date(_), do: nil

  # True when (date, hour) is the same calendar hour as `now`. `now` is a
  # DateTime/NaiveDateTime (or nil → never). Drives the current-hour column
  # highlight at `:hour` zoom.
  defp hour_is_now?(_date, _hour, nil), do: false

  defp hour_is_now?(date, hour, now),
    do: date == to_date(now) and hour == now.hour

  defp slot_is_now?(_date, _minute_of_day, _minutes_per_slot, nil), do: false

  defp slot_is_now?(date, minute_of_day, minutes_per_slot, now) do
    now_minute = now.hour * 60 + now.minute

    date == to_date(now) and now_minute >= minute_of_day and
      now_minute < minute_of_day + minutes_per_slot
  end

  defp parse_row_height(height) when is_binary(height) do
    case Float.parse(height) do
      {val, "rem"} -> round(val * 16)
      {val, "px"} -> round(val)
      _ -> @default_row_px
    end
  end

  defp parse_row_height(_), do: @default_row_px
end