Last active
June 27, 2026 15:13
-
-
Save reason211/da91c6461eea41d0f72915bc8b260c2e to your computer and use it in GitHub Desktop.
SeqWalk preview: interactive e-commerce order-to-delivery sequence diagram
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>SeqWalk Example: E-commerce Order To Delivery</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%232f7df6'/%3E%3Cpath d='M9 11h14M9 16h14M9 21h9' stroke='white' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E" /> | |
| <style> | |
| :root { | |
| color-scheme: light; | |
| --bg: #edf4fb; | |
| --ink: #142033; | |
| --muted: #657386; | |
| --line: rgba(88, 118, 150, 0.22); | |
| --glass: rgba(255, 255, 255, 0.72); | |
| --glass-strong: rgba(255, 255, 255, 0.9); | |
| --blue: #2f7df6; | |
| --cyan: #16a4c4; | |
| --green: #18a36c; | |
| --orange: #df8f32; | |
| --red: #d95f5f; | |
| --violet: #7d6bd6; | |
| --shadow: 0 18px 50px rgba(43, 72, 105, 0.15); | |
| --inner: inset 0 1px 0 rgba(255,255,255,0.86), inset 0 -1px 0 rgba(84,116,145,0.12); | |
| --lane-count: 8; | |
| --rail-top: 84px; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html { | |
| scroll-behavior: smooth; | |
| } | |
| body { | |
| margin: 0; | |
| min-width: 1040px; | |
| font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; | |
| color: var(--ink); | |
| background: | |
| linear-gradient(115deg, rgba(47,125,246,0.12), transparent 34%), | |
| linear-gradient(245deg, rgba(22,164,196,0.13), transparent 40%), | |
| linear-gradient(180deg, #f8fbff 0%, #ebf4fb 48%, #edf7f3 100%); | |
| overflow-x: auto; | |
| } | |
| body::before { | |
| content: ""; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| background-image: | |
| linear-gradient(rgba(47,125,246,0.045) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(47,125,246,0.045) 1px, transparent 1px); | |
| background-size: 52px 52px; | |
| mask-image: linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent); | |
| } | |
| button { | |
| font: inherit; | |
| } | |
| .shell { | |
| width: min(1320px, calc(100vw - 32px)); | |
| min-width: 1000px; | |
| margin: 16px auto 56px; | |
| padding: 16px; | |
| border: 1px solid rgba(138,166,196,0.28); | |
| border-radius: 30px; | |
| background: rgba(255,255,255,0.34); | |
| box-shadow: 0 24px 76px rgba(30,66,101,0.17); | |
| backdrop-filter: blur(26px) saturate(1.45); | |
| } | |
| .topbar { | |
| position: sticky; | |
| top: 12px; | |
| z-index: 24; | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 14px; | |
| align-items: center; | |
| padding: 12px 14px; | |
| border: 1px solid rgba(138,166,196,0.3); | |
| border-radius: 24px; | |
| background: rgba(255,255,255,0.76); | |
| box-shadow: var(--shadow), var(--inner); | |
| backdrop-filter: blur(24px) saturate(1.7); | |
| } | |
| .brand { | |
| min-width: 0; | |
| } | |
| .brand h1 { | |
| margin: 0; | |
| font-size: 20px; | |
| line-height: 1.2; | |
| font-weight: 780; | |
| letter-spacing: 0; | |
| } | |
| .brand p { | |
| margin: 4px 0 0; | |
| color: var(--muted); | |
| font-size: 13px; | |
| line-height: 1.35; | |
| } | |
| .tools { | |
| display: inline-flex; | |
| gap: 8px; | |
| align-items: center; | |
| } | |
| .tool-button { | |
| height: 34px; | |
| border: 0; | |
| border-radius: 14px; | |
| padding: 0 12px; | |
| color: #314055; | |
| background: rgba(246,250,254,0.78); | |
| box-shadow: var(--inner); | |
| cursor: pointer; | |
| white-space: nowrap; | |
| } | |
| .tool-button:hover, | |
| .tool-button.active { | |
| color: #0f5bd4; | |
| background: rgba(255,255,255,0.96); | |
| box-shadow: 0 8px 22px rgba(47,125,246,0.14), var(--inner); | |
| } | |
| .participant-rail { | |
| position: sticky; | |
| top: var(--rail-top); | |
| z-index: 20; | |
| margin-top: 14px; | |
| padding: 10px; | |
| border: 1px solid rgba(138,166,196,0.32); | |
| border-radius: 26px; | |
| background: rgba(255,255,255,0.76); | |
| box-shadow: 0 16px 46px rgba(43,72,105,0.13), var(--inner); | |
| backdrop-filter: blur(28px) saturate(1.85); | |
| } | |
| .rail-grid, | |
| .lifeline-layer, | |
| .step { | |
| display: grid; | |
| grid-template-columns: repeat(var(--lane-count), minmax(90px, 1fr)); | |
| gap: 8px; | |
| } | |
| .actor { | |
| position: relative; | |
| min-height: 82px; | |
| padding: 10px 8px 13px; | |
| border: 1px solid rgba(138,166,196,0.28); | |
| border-radius: 18px; | |
| background: rgba(255,255,255,0.62); | |
| box-shadow: var(--inner); | |
| transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease, opacity 180ms ease, filter 180ms ease; | |
| overflow: hidden; | |
| cursor: pointer; | |
| --actor-color: var(--blue); | |
| } | |
| .actor::after { | |
| content: ""; | |
| position: absolute; | |
| left: 10px; | |
| right: 10px; | |
| bottom: 7px; | |
| height: 3px; | |
| border-radius: 3px; | |
| background: var(--actor-color); | |
| opacity: 0.5; | |
| transform: scaleX(0.22); | |
| transform-origin: left center; | |
| transition: transform 180ms ease, opacity 180ms ease; | |
| } | |
| .actor small, | |
| .actor strong { | |
| display: block; | |
| white-space: normal; | |
| overflow: visible; | |
| overflow-wrap: anywhere; | |
| text-overflow: clip; | |
| } | |
| .actor small { | |
| min-height: 22px; | |
| color: var(--muted); | |
| font-size: 10px; | |
| line-height: 1.18; | |
| } | |
| .actor strong { | |
| margin-top: 4px; | |
| color: #1c2c40; | |
| font-size: 12px; | |
| line-height: 1.18; | |
| font-weight: 750; | |
| } | |
| .actor.active, | |
| .actor.related, | |
| .actor.locked { | |
| border-color: color-mix(in srgb, var(--actor-color) 72%, white); | |
| background: rgba(255,255,255,0.96); | |
| box-shadow: 0 14px 30px color-mix(in srgb, var(--actor-color) 16%, transparent), var(--inner); | |
| transform: translateY(-2px); | |
| } | |
| .actor.locked { | |
| border-color: color-mix(in srgb, var(--actor-color) 88%, white); | |
| box-shadow: 0 0 0 2px color-mix(in srgb, var(--actor-color) 18%, transparent), 0 16px 34px color-mix(in srgb, var(--actor-color) 20%, transparent), var(--inner); | |
| transform: translateY(-3px); | |
| } | |
| .actor.active::after, | |
| .actor.related::after, | |
| .actor.locked::after { | |
| opacity: 1; | |
| transform: scaleX(1); | |
| } | |
| .has-click-highlight .actor:not(.locked) { | |
| opacity: 0.24; | |
| filter: saturate(0.55) contrast(0.88); | |
| transform: none; | |
| } | |
| .timeline-wrap { | |
| position: relative; | |
| margin-top: 14px; | |
| border: 1px solid rgba(138,166,196,0.28); | |
| border-radius: 28px; | |
| background: rgba(255,255,255,0.42); | |
| box-shadow: var(--shadow), var(--inner); | |
| overflow: hidden; | |
| backdrop-filter: blur(22px) saturate(1.5); | |
| } | |
| .lifeline-layer { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 0; | |
| pointer-events: none; | |
| padding: 0 10px; | |
| } | |
| .lifeline { | |
| position: relative; | |
| min-width: 0; | |
| } | |
| .lifeline-line { | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| left: 50%; | |
| width: 0; | |
| border-left: 1.5px dashed rgba(74,103,134,0.24); | |
| transform: translateX(-50%); | |
| transition: border-color 180ms ease, filter 180ms ease, opacity 180ms ease, border-width 180ms ease; | |
| } | |
| .lifeline-line::before { | |
| content: ""; | |
| position: absolute; | |
| top: 0; | |
| left: -4px; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--line-color, var(--blue)); | |
| opacity: 0.28; | |
| box-shadow: 0 0 0 4px color-mix(in srgb, var(--line-color) 12%, transparent); | |
| transition: opacity 180ms ease, transform 180ms ease; | |
| } | |
| .lifeline.active .lifeline-line, | |
| .lifeline.related .lifeline-line, | |
| .lifeline.locked .lifeline-line, | |
| .focus-store .lifeline[data-kind="store"] .lifeline-line, | |
| .focus-store .lifeline.store-related .lifeline-line { | |
| border-left-color: var(--line-color, var(--blue)); | |
| border-left-width: 3px; | |
| filter: drop-shadow(0 0 10px color-mix(in srgb, var(--line-color) 48%, transparent)); | |
| } | |
| .lifeline.active .lifeline-line::before, | |
| .lifeline.related .lifeline-line::before, | |
| .lifeline.locked .lifeline-line::before, | |
| .focus-store .lifeline[data-kind="store"] .lifeline-line::before, | |
| .focus-store .lifeline.store-related .lifeline-line::before { | |
| opacity: 1; | |
| transform: scale(1.35); | |
| } | |
| .has-click-highlight .lifeline:not(.locked) .lifeline-line { | |
| opacity: 0.18; | |
| filter: saturate(0.5); | |
| } | |
| .sequence-section { | |
| position: relative; | |
| z-index: 1; | |
| scroll-margin-top: 220px; | |
| } | |
| .section-title { | |
| display: grid; | |
| grid-template-columns: auto 1fr auto; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 13px 18px; | |
| border-bottom: 1px solid rgba(138,166,196,0.2); | |
| background: rgba(247,251,255,0.78); | |
| backdrop-filter: blur(18px) saturate(1.6); | |
| } | |
| .section-title h2 { | |
| margin: 0; | |
| font-size: 17px; | |
| line-height: 1.2; | |
| } | |
| .section-title p { | |
| margin: 0; | |
| color: var(--muted); | |
| font-size: 13px; | |
| line-height: 1.35; | |
| } | |
| .tag { | |
| border: 1px solid rgba(138,166,196,0.28); | |
| border-radius: 999px; | |
| padding: 5px 9px; | |
| color: #314055; | |
| background: rgba(255,255,255,0.72); | |
| font-size: 12px; | |
| white-space: nowrap; | |
| } | |
| .steps { | |
| padding: 18px 10px; | |
| } | |
| .step { | |
| position: relative; | |
| min-height: 208px; | |
| padding: 18px 0 22px; | |
| border-bottom: 1px solid rgba(138,166,196,0.16); | |
| scroll-margin-top: 190px; | |
| isolation: isolate; | |
| } | |
| .step:last-child { | |
| border-bottom: 0; | |
| } | |
| .time-mark { | |
| position: absolute; | |
| left: 0; | |
| top: 30px; | |
| width: 64px; | |
| color: #6b7b8e; | |
| font-size: 12px; | |
| text-align: right; | |
| transform: translateX(-4px); | |
| } | |
| .flow { | |
| position: absolute; | |
| left: var(--flow-left, 0); | |
| top: var(--flow-y, 42px); | |
| z-index: 2; | |
| width: var(--flow-width, 80px); | |
| min-width: 26px; | |
| height: 2px; | |
| background: linear-gradient(90deg, var(--flow-color), color-mix(in srgb, var(--flow-color) 48%, transparent)); | |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.45); | |
| opacity: 0.72; | |
| transform: translateY(-50%); | |
| transition: opacity 180ms ease, height 180ms ease, filter 180ms ease, background 180ms ease; | |
| } | |
| .flow.reverse { | |
| background: linear-gradient(90deg, color-mix(in srgb, var(--flow-color) 48%, transparent), var(--flow-color)); | |
| } | |
| .flow::before, | |
| .flow::after { | |
| content: ""; | |
| position: absolute; | |
| top: 50%; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--flow-color); | |
| transform: translateY(-50%); | |
| box-shadow: 0 0 0 4px color-mix(in srgb, var(--flow-color) 15%, transparent); | |
| } | |
| .flow::before { | |
| left: -1px; | |
| } | |
| .flow::after { | |
| right: -1px; | |
| } | |
| .flow-label { | |
| position: absolute; | |
| left: var(--label-left, 50%); | |
| top: var(--label-top, -28px); | |
| z-index: 7; | |
| min-width: max-content; | |
| max-width: none; | |
| padding: 4px 8px; | |
| border: 1px solid color-mix(in srgb, var(--flow-color) 28%, rgba(138,166,196,0.22)); | |
| border-radius: 999px; | |
| color: #3f5064; | |
| background: rgba(255,255,255,0.82); | |
| box-shadow: 0 8px 18px rgba(43,72,105,0.1), var(--inner); | |
| font-size: 11px; | |
| line-height: 1.15; | |
| text-align: center; | |
| white-space: nowrap; | |
| overflow: visible; | |
| text-overflow: clip; | |
| pointer-events: none; | |
| transform: translateX(-50%); | |
| backdrop-filter: blur(10px); | |
| } | |
| .step.in-view > .flow, | |
| .flow.related, | |
| .flow.locked, | |
| .focus-store .step.store-related > .flow { | |
| height: 3px; | |
| opacity: 1; | |
| filter: drop-shadow(0 0 8px color-mix(in srgb, var(--flow-color) 42%, transparent)); | |
| } | |
| .flow.related, | |
| .flow.locked { | |
| height: 4px; | |
| filter: drop-shadow(0 0 10px color-mix(in srgb, var(--flow-color) 52%, transparent)); | |
| } | |
| .has-click-highlight .flow:not(.locked) { | |
| opacity: 0.16; | |
| filter: saturate(0.45); | |
| } | |
| .event-card { | |
| grid-column: var(--at) / span var(--span, 2); | |
| position: relative; | |
| align-self: start; | |
| z-index: 3; | |
| left: var(--card-x, 0px); | |
| width: var(--card-width, auto); | |
| margin-top: var(--card-y, 78px); | |
| min-height: 86px; | |
| padding: 13px 13px 12px; | |
| border: 1px solid color-mix(in srgb, var(--flow-color) 38%, rgba(138,166,196,0.25)); | |
| border-radius: 18px; | |
| background: rgba(255,255,255,0.76); | |
| box-shadow: 0 14px 36px rgba(43,72,105,0.14), var(--inner); | |
| backdrop-filter: blur(18px) saturate(1.4); | |
| transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease, opacity 180ms ease, filter 180ms ease; | |
| cursor: pointer; | |
| } | |
| .event-card:hover, | |
| .event-card.related, | |
| .event-card.locked, | |
| .step.in-view .event-card { | |
| transform: translateY(-2px); | |
| background: rgba(255,255,255,0.92); | |
| box-shadow: 0 18px 42px rgba(43,72,105,0.18), var(--inner); | |
| } | |
| .event-card.locked { | |
| transform: translateY(-3px); | |
| background: rgba(255,255,255,0.98); | |
| box-shadow: 0 0 0 2px color-mix(in srgb, var(--flow-color) 18%, transparent), 0 20px 46px rgba(43,72,105,0.2), var(--inner); | |
| } | |
| .has-click-highlight .event-card:not(.locked) { | |
| opacity: 0.28; | |
| filter: saturate(0.55) contrast(0.9); | |
| transform: none; | |
| box-shadow: 0 8px 18px rgba(43,72,105,0.08), var(--inner); | |
| } | |
| .event-card h3 { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 7px; | |
| margin: 0; | |
| color: #18283c; | |
| font-size: 14px; | |
| line-height: 1.25; | |
| font-weight: 780; | |
| } | |
| .card-index { | |
| display: inline-flex; | |
| flex: 0 0 auto; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 28px; | |
| height: 20px; | |
| padding: 0 6px; | |
| border-radius: 999px; | |
| color: var(--flow-color, var(--blue)); | |
| background: color-mix(in srgb, var(--flow-color, var(--blue)) 12%, white); | |
| box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--flow-color, var(--blue)) 24%, transparent); | |
| font-size: 11px; | |
| font-weight: 800; | |
| line-height: 1; | |
| } | |
| .event-card p { | |
| margin: 8px 0 0; | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.55; | |
| } | |
| .event-card code { | |
| padding: 1px 5px; | |
| border-radius: 6px; | |
| color: #0d5ecd; | |
| background: rgba(47,125,246,0.08); | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| font-size: 11px; | |
| overflow-wrap: anywhere; | |
| } | |
| .chips { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin-top: 10px; | |
| } | |
| .chip { | |
| display: inline-flex; | |
| align-items: center; | |
| min-height: 22px; | |
| padding: 0 8px; | |
| border: 1px solid rgba(138,166,196,0.24); | |
| border-radius: 999px; | |
| color: #405063; | |
| background: rgba(247,251,255,0.74); | |
| font-size: 11px; | |
| line-height: 1.2; | |
| } | |
| .focus-store .actor:not([data-kind="store"]):not(.active):not(.related):not(.locked), | |
| .focus-store .lifeline:not([data-kind="store"]):not(.active):not(.related):not(.locked) .lifeline-line, | |
| .focus-store .step:not(.store-related) .event-card, | |
| .focus-store .step:not(.store-related) > .flow { | |
| opacity: 0.26; | |
| filter: saturate(0.55); | |
| } | |
| .focus-store .actor[data-kind="store"], | |
| .focus-store .actor.store-related, | |
| .focus-store .step.store-related .event-card { | |
| border-color: color-mix(in srgb, var(--green) 70%, white); | |
| background: rgba(255,255,255,0.96); | |
| box-shadow: 0 16px 34px rgba(24,163,108,0.16), var(--inner); | |
| } | |
| @media (max-width: 1180px) { | |
| .shell { | |
| width: calc(100vw - 20px); | |
| min-width: 1000px; | |
| } | |
| .topbar { | |
| top: 8px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="shell" id="app"> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <h1>E-commerce Order To Delivery</h1> | |
| <p>Scroll downward to reveal the flow step by step while the sticky component rail highlights the services involved right now.</p> | |
| </div> | |
| <div class="tools"> | |
| <button class="tool-button" id="storeToggle" type="button" aria-pressed="false">Highlight stores</button> | |
| </div> | |
| </header> | |
| <section class="participant-rail" aria-label="Participants"> | |
| <div class="rail-grid" id="actorRail"></div> | |
| </section> | |
| <section class="timeline-wrap" id="diagram" aria-label="Sequence diagram"> | |
| <div class="lifeline-layer" id="lifelineLayer" aria-hidden="true"></div> | |
| <section class="sequence-section" id="section-checkout" data-section="checkout"> | |
| <div class="section-title"> | |
| <h2>A Checkout And Stock Hold</h2> | |
| <p>The storefront creates a draft order, then the order service reserves stock before payment starts.</p> | |
| <span class="tag">Checkout</span> | |
| </div> | |
| <div class="steps"> | |
| <article class="step" id="a1" data-actors="shopper storefront order" style="--flow-color: var(--cyan)"> | |
| <span class="time-mark">A 01</span> | |
| <div class="flow" data-from="shopper" data-to="storefront" data-label="Submit cart and delivery address"></div> | |
| <div class="flow" data-from="storefront" data-to="order" data-label="Create checkout draft" style="--flow-y: 92px"></div> | |
| <div class="event-card" data-card-id="A1" data-actors="shopper storefront order" style="--at: 2; --span: 3; --card-y: 72px"> | |
| <h3><span class="card-index">A1</span><span>Checkout starts</span></h3> | |
| <p>The shopper confirms items and address. The storefront opens a draft order so later services can attach stock, payment, and delivery state.</p> | |
| <div class="chips"><span class="chip">cart</span><span class="chip">draft order</span></div> | |
| </div> | |
| </article> | |
| <article class="step" id="a2" data-actors="order inventory ledger" style="--flow-color: var(--green)"> | |
| <span class="time-mark">A 02</span> | |
| <div class="flow" data-from="order" data-to="inventory" data-label="Reserve sellable stock for 15 minutes"></div> | |
| <div class="flow" data-from="inventory" data-to="ledger" data-label="Record stock hold and draft order" style="--flow-y: 94px"></div> | |
| <div class="event-card" data-card-id="A2" data-actors="order inventory ledger" style="--at: 3; --span: 4; --card-y: 80px"> | |
| <h3><span class="card-index">A2</span><span>Stock is held</span></h3> | |
| <p>Inventory protects the selected SKUs while the order ledger keeps a traceable draft state for payment and timeout cleanup.</p> | |
| <div class="chips"><span class="chip">inventory store</span><span class="chip">order ledger</span></div> | |
| </div> | |
| </article> | |
| </div> | |
| </section> | |
| <section class="sequence-section" id="section-payment" data-section="payment"> | |
| <div class="section-title"> | |
| <h2>B Payment And Confirmation</h2> | |
| <p>Payment authorization turns the draft into a paid order and notifies the shopper.</p> | |
| <span class="tag">Payment</span> | |
| </div> | |
| <div class="steps"> | |
| <article class="step" id="b1" data-actors="storefront payment order" style="--flow-color: var(--blue)"> | |
| <span class="time-mark">B 01</span> | |
| <div class="flow" data-from="storefront" data-to="payment" data-label="Open payment session"></div> | |
| <div class="flow reverse" data-from="payment" data-to="order" data-label="Authorized payment callback" style="--flow-y: 92px"></div> | |
| <div class="event-card" data-card-id="B1" data-actors="storefront payment order" style="--at: 3; --span: 4; --card-y: 76px"> | |
| <h3><span class="card-index">B1</span><span>Payment is authorized</span></h3> | |
| <p>The gateway confirms the payment. The order service receives a callback and can safely move the order out of draft state.</p> | |
| <div class="chips"><span class="chip">gateway</span><span class="chip">callback</span></div> | |
| </div> | |
| </article> | |
| <article class="step" id="b2" data-actors="order ledger notify shopper" style="--flow-color: var(--violet)"> | |
| <span class="time-mark">B 02</span> | |
| <div class="flow" data-from="order" data-to="ledger" data-label="Mark order paid and keep audit trail"></div> | |
| <div class="flow" data-from="order" data-to="notify" data-label="Send order confirmation" style="--flow-y: 92px"></div> | |
| <div class="flow reverse" data-from="notify" data-to="shopper" data-label="Confirmation email and app push" style="--flow-y: 138px"></div> | |
| <div class="event-card" data-card-id="B2" data-actors="order ledger notify shopper" style="--at: 5; --span: 4; --card-y: 80px"> | |
| <h3><span class="card-index">B2</span><span>Order is confirmed</span></h3> | |
| <p>The paid state is stored once, then the notification service tells the shopper that fulfillment will begin.</p> | |
| <div class="chips"><span class="chip">paid order</span><span class="chip">notification</span></div> | |
| </div> | |
| </article> | |
| </div> | |
| </section> | |
| <section class="sequence-section" id="section-fulfillment" data-section="fulfillment"> | |
| <div class="section-title"> | |
| <h2>C Warehouse Fulfillment</h2> | |
| <p>The warehouse picks and packs the order, then hands it to the courier with a tracking number.</p> | |
| <span class="tag">Fulfillment</span> | |
| </div> | |
| <div class="steps"> | |
| <article class="step" id="c1" data-actors="order warehouse inventory" style="--flow-color: var(--orange)"> | |
| <span class="time-mark">C 01</span> | |
| <div class="flow" data-from="order" data-to="warehouse" data-label="Release pick ticket"></div> | |
| <div class="flow" data-from="warehouse" data-to="inventory" data-label="Deduct reserved stock after pick" style="--flow-y: 92px"></div> | |
| <div class="event-card" data-card-id="C1" data-actors="order warehouse inventory" style="--at: 4; --span: 4; --card-y: 78px"> | |
| <h3><span class="card-index">C1</span><span>Warehouse picks items</span></h3> | |
| <p>The order service releases a pick ticket. Warehouse staff scan the shelf items and inventory converts the hold into a real deduction.</p> | |
| <div class="chips"><span class="chip">pick ticket</span><span class="chip">stock deduction</span></div> | |
| </div> | |
| </article> | |
| <article class="step" id="c2" data-actors="warehouse courier order" style="--flow-color: var(--red)"> | |
| <span class="time-mark">C 02</span> | |
| <div class="flow" data-from="warehouse" data-to="courier" data-label="Create waybill and request pickup"></div> | |
| <div class="flow reverse" data-from="courier" data-to="order" data-label="Return tracking number" style="--flow-y: 96px"></div> | |
| <div class="event-card" data-card-id="C2" data-actors="warehouse courier order" style="--at: 6; --span: 3; --card-y: 82px"> | |
| <h3><span class="card-index">C2</span><span>Parcel is handed off</span></h3> | |
| <p>The courier accepts the parcel and returns a tracking number that becomes the shopper-facing shipment identity.</p> | |
| <div class="chips"><span class="chip">waybill</span><span class="chip">tracking</span></div> | |
| </div> | |
| </article> | |
| </div> | |
| </section> | |
| <section class="sequence-section" id="section-delivery" data-section="delivery"> | |
| <div class="section-title"> | |
| <h2>D Delivery And Sign-off</h2> | |
| <p>Shipment events update the shopper until the courier confirms signed delivery.</p> | |
| <span class="tag">Delivery</span> | |
| </div> | |
| <div class="steps"> | |
| <article class="step" id="d1" data-actors="courier notify shopper" style="--flow-color: var(--cyan)"> | |
| <span class="time-mark">D 01</span> | |
| <div class="flow" data-from="courier" data-to="notify" data-label="Shipment status and ETA updates"></div> | |
| <div class="flow reverse" data-from="notify" data-to="shopper" data-label="Tracking link and delivery reminders" style="--flow-y: 92px"></div> | |
| <div class="event-card" data-card-id="D1" data-actors="courier notify shopper" style="--at: 7; --span: 3; --card-y: 78px"> | |
| <h3><span class="card-index">D1</span><span>Shopper sees delivery progress</span></h3> | |
| <p>Courier status changes are translated into useful messages instead of exposing internal logistics codes.</p> | |
| <div class="chips"><span class="chip">tracking</span><span class="chip">ETA</span></div> | |
| </div> | |
| </article> | |
| <article class="step" id="d2" data-actors="courier order ledger notify shopper" style="--flow-color: var(--green)"> | |
| <span class="time-mark">D 02</span> | |
| <div class="flow" data-from="courier" data-to="order" data-label="Signed delivery receipt"></div> | |
| <div class="flow" data-from="order" data-to="ledger" data-label="Close order and save delivery proof" style="--flow-y: 92px"></div> | |
| <div class="flow" data-from="order" data-to="notify" data-label="Send delivery complete notice" style="--flow-y: 138px"></div> | |
| <div class="flow reverse" data-from="notify" data-to="shopper" data-label="Receipt and after-sales entry" style="--flow-y: 184px"></div> | |
| <div class="event-card" data-card-id="D2" data-actors="courier order ledger notify shopper" style="--at: 3; --span: 5; --card-y: 92px"> | |
| <h3><span class="card-index">D2</span><span>Order is signed and closed</span></h3> | |
| <p>The courier confirms receipt. The order ledger stores proof, then the shopper receives a final delivery message and after-sales link.</p> | |
| <div class="chips"><span class="chip">signed</span><span class="chip">closed order</span><span class="chip">proof stored</span></div> | |
| </div> | |
| </article> | |
| </div> | |
| </section> | |
| </section> | |
| </main> | |
| <script> | |
| const actors = [ | |
| { id: "shopper", label: "Shopper", sub: "web / mobile", color: "#16a4c4", kind: "user" }, | |
| { id: "storefront", label: "Storefront", sub: "cart and checkout UI", color: "#2f7df6", kind: "ui" }, | |
| { id: "order", label: "Order Service", sub: "order state machine", color: "#7d6bd6", kind: "service" }, | |
| { id: "inventory", label: "Inventory", sub: "stock reservation", color: "#18a36c", kind: "store" }, | |
| { id: "payment", label: "Payment Gateway", sub: "authorization", color: "#df8f32", kind: "external" }, | |
| { id: "ledger", label: "Order Ledger", sub: "order database", color: "#18a36c", kind: "store" }, | |
| { id: "warehouse", label: "Warehouse", sub: "pick and pack", color: "#d95f5f", kind: "ops" }, | |
| { id: "courier", label: "Courier", sub: "pickup and delivery", color: "#d95f5f", kind: "external" }, | |
| { id: "notify", label: "Notification", sub: "email and app push", color: "#16a4c4", kind: "service" } | |
| ]; | |
| const app = document.getElementById("app"); | |
| const actorRail = document.getElementById("actorRail"); | |
| const lifelineLayer = document.getElementById("lifelineLayer"); | |
| const storeToggle = document.getElementById("storeToggle"); | |
| document.documentElement.style.setProperty("--lane-count", actors.length); | |
| actorRail.innerHTML = actors.map((actor) => ` | |
| <div class="actor" data-actor="${actor.id}" data-kind="${actor.kind}" style="--actor-color:${actor.color}" role="button" tabindex="0" aria-pressed="false"> | |
| <small>${actor.sub}</small> | |
| <strong>${actor.label}</strong> | |
| </div> | |
| `).join(""); | |
| lifelineLayer.innerHTML = actors.map((actor) => ` | |
| <div class="lifeline" data-actor="${actor.id}" data-kind="${actor.kind}" style="--line-color:${actor.color}"> | |
| <span class="lifeline-line"></span> | |
| </div> | |
| `).join(""); | |
| const steps = [...document.querySelectorAll(".step")]; | |
| const cards = [...document.querySelectorAll(".event-card")]; | |
| const cardsById = new Map(cards.map((card) => [card.dataset.cardId, card])); | |
| const actorsById = new Map([...document.querySelectorAll(".actor")].map((el) => [el.dataset.actor, el])); | |
| const lifelinesById = new Map([...document.querySelectorAll(".lifeline")].map((el) => [el.dataset.actor, el])); | |
| const actorsMetaById = new Map(actors.map((actor) => [actor.id, actor])); | |
| let viewportStep = steps[0]; | |
| let hoverCardId = null; | |
| let hoverActorId = null; | |
| let selectedCardId = null; | |
| let selectedActorId = null; | |
| function stepActorIds(step) { | |
| const explicit = (step.dataset.actors || "").split(/\s+/).filter(Boolean); | |
| const flowActors = [...step.querySelectorAll(".flow")] | |
| .flatMap((flow) => [flow.dataset.from, flow.dataset.to]) | |
| .filter(Boolean); | |
| return [...new Set([...explicit, ...flowActors])]; | |
| } | |
| function cardActorIds(card) { | |
| const explicit = (card?.dataset.actors || "").split(/\s+/).filter(Boolean); | |
| return explicit.length ? explicit : stepActorIds(card.closest(".step")); | |
| } | |
| function cardsInStep(step) { | |
| return [...step.querySelectorAll(".event-card")]; | |
| } | |
| function visibleSteps() { | |
| return steps.filter((step) => step.offsetParent !== null); | |
| } | |
| function visibleFlows(scopeStep = null) { | |
| const scope = scopeStep ? [scopeStep] : visibleSteps(); | |
| return scope.flatMap((step) => [...step.querySelectorAll(".flow")]); | |
| } | |
| function flowTouches(flow, actorIds) { | |
| return actorIds.includes(flow.dataset.from) || actorIds.includes(flow.dataset.to); | |
| } | |
| function cardTouches(card, actorIds) { | |
| return cardActorIds(card).some((id) => actorIds.includes(id)); | |
| } | |
| function directFlowsForActors(actorIds, scopeStep = null) { | |
| const ids = [...new Set(actorIds.filter(Boolean))]; | |
| return visibleFlows(scopeStep).filter((flow) => flowTouches(flow, ids)); | |
| } | |
| function directCardsForActors(actorIds) { | |
| const ids = [...new Set(actorIds.filter(Boolean))]; | |
| return cards.filter((card) => card.offsetParent !== null && cardTouches(card, ids)); | |
| } | |
| function flowEndpointIds(flows) { | |
| return [...new Set(flows.flatMap((flow) => [flow.dataset.from, flow.dataset.to]).filter(Boolean))]; | |
| } | |
| function overlaps(a, b, pad = 0) { | |
| return !(a.right <= b.left + pad || a.left >= b.right - pad || a.bottom <= b.top + pad || a.top >= b.bottom - pad); | |
| } | |
| function clamp(value, min, max) { | |
| return Math.min(max, Math.max(min, value)); | |
| } | |
| function actorCenterInStep(actorId, step) { | |
| const lifeline = lifelinesById.get(actorId); | |
| if (!lifeline) return null; | |
| const lineRect = lifeline.querySelector(".lifeline-line").getBoundingClientRect(); | |
| const stepRect = step.getBoundingClientRect(); | |
| return lineRect.left + lineRect.width / 2 - stepRect.left; | |
| } | |
| function markStoreRelated() { | |
| steps.forEach((step) => { | |
| const related = step.dataset.kind === "store" || | |
| stepActorIds(step).some((id) => actorsMetaById.get(id)?.kind === "store"); | |
| step.classList.toggle("store-related", related); | |
| }); | |
| actors.forEach((actor) => { | |
| const related = actor.kind === "store"; | |
| actorsById.get(actor.id)?.classList.toggle("store-related", related); | |
| lifelinesById.get(actor.id)?.classList.toggle("store-related", related); | |
| }); | |
| } | |
| function alignFlows() { | |
| const placedByStep = new Map(); | |
| document.querySelectorAll(".flow").forEach((flow) => { | |
| if (flow.dataset.label && !flow.querySelector(".flow-label")) { | |
| const label = document.createElement("span"); | |
| label.className = "flow-label"; | |
| label.textContent = flow.dataset.label; | |
| flow.appendChild(label); | |
| } | |
| const step = flow.closest(".step"); | |
| const from = actorCenterInStep(flow.dataset.from, step); | |
| const to = actorCenterInStep(flow.dataset.to, step); | |
| if (from === null || to === null) return; | |
| const left = Math.min(from, to); | |
| const width = Math.max(28, Math.abs(to - from)); | |
| flow.style.setProperty("--flow-left", `${left}px`); | |
| flow.style.setProperty("--flow-width", `${width}px`); | |
| placeFlowLabel(flow, step, left, width, placedByStep.get(step) || []); | |
| const labelRect = flow.querySelector(".flow-label")?.getBoundingClientRect(); | |
| if (labelRect) { | |
| placedByStep.set(step, [...(placedByStep.get(step) || []), labelRect]); | |
| } | |
| }); | |
| } | |
| function placeFlowLabel(flow, step, left, width, placedRects) { | |
| const label = flow.querySelector(".flow-label"); | |
| if (!label) return; | |
| label.style.setProperty("--label-left", "50%"); | |
| label.style.setProperty("--label-top", "-28px"); | |
| const stepRect = step.getBoundingClientRect(); | |
| const flowRect = flow.getBoundingClientRect(); | |
| const labelRect = label.getBoundingClientRect(); | |
| const labelWidth = labelRect.width; | |
| const labelHeight = labelRect.height; | |
| const safe = 8; | |
| const preferredCenter = left + width / 2; | |
| const maxCenter = Math.max(labelWidth / 2 + safe, step.clientWidth - labelWidth / 2 - safe); | |
| const center = clamp(preferredCenter, labelWidth / 2 + safe, maxCenter); | |
| const cardRects = cardsInStep(step).map((card) => card.getBoundingClientRect()); | |
| const endpointRects = [ | |
| { left: flowRect.left - 8, right: flowRect.left + 10, top: flowRect.top - 8, bottom: flowRect.bottom + 8 }, | |
| { left: flowRect.right - 10, right: flowRect.right + 8, top: flowRect.top - 8, bottom: flowRect.bottom + 8 } | |
| ]; | |
| const slots = [-32, 18, -54, 42, -76, 64]; | |
| const chosenTop = slots.find((top) => { | |
| const candidate = { | |
| left: stepRect.left + center - labelWidth / 2, | |
| right: stepRect.left + center + labelWidth / 2, | |
| top: flowRect.top + top, | |
| bottom: flowRect.top + top + labelHeight | |
| }; | |
| return !cardRects.some((rect) => overlaps(rect, candidate, 4)) && | |
| !endpointRects.some((rect) => overlaps(rect, candidate, 4)) && | |
| !placedRects.some((rect) => overlaps(rect, candidate, 6)); | |
| }) ?? -54; | |
| flow.style.setProperty("--label-left", `${center - left}px`); | |
| flow.style.setProperty("--label-top", `${chosenTop}px`); | |
| } | |
| function resetHighlightClasses() { | |
| actorsById.forEach((el) => el.classList.remove("active", "related", "locked")); | |
| lifelinesById.forEach((el) => el.classList.remove("active", "related", "locked")); | |
| steps.forEach((step) => step.classList.remove("in-view", "related", "locked")); | |
| document.querySelectorAll(".flow").forEach((flow) => flow.classList.remove("related", "locked")); | |
| cards.forEach((card) => card.classList.remove("related", "locked")); | |
| } | |
| function applyActorClasses(actorIds, level) { | |
| actorIds.forEach((id) => { | |
| actorsById.get(id)?.classList.add(level); | |
| lifelinesById.get(id)?.classList.add(level); | |
| }); | |
| } | |
| function applyFlowClasses(flows, level) { | |
| flows.forEach((flow) => { | |
| flow.classList.add(level); | |
| flow.closest(".step")?.classList.add(level); | |
| }); | |
| } | |
| function applyCardFocus(card, level = "related") { | |
| if (!card) return; | |
| const baseActorIds = cardActorIds(card); | |
| const directFlows = directFlowsForActors(baseActorIds, card.closest(".step")); | |
| const actorIds = [...new Set([...baseActorIds, ...flowEndpointIds(directFlows)])]; | |
| card.closest(".step")?.classList.add(level); | |
| applyActorClasses(actorIds, level); | |
| applyFlowClasses(directFlows, level); | |
| card.classList.add(level); | |
| } | |
| function applyActorFocus(actorId, level = "related") { | |
| if (!actorId) return; | |
| const directFlows = directFlowsForActors([actorId]); | |
| const directCards = directCardsForActors([actorId]); | |
| const actorIds = [...new Set([actorId, ...flowEndpointIds(directFlows)])]; | |
| applyActorClasses(actorIds, level); | |
| applyFlowClasses(directFlows, level); | |
| directCards.forEach((card) => { | |
| card.classList.add(level); | |
| card.closest(".step")?.classList.add(level); | |
| }); | |
| } | |
| function applyStoreFocus(level = "locked") { | |
| const storeIds = actors.filter((actor) => actor.kind === "store").map((actor) => actor.id); | |
| const directFlows = directFlowsForActors(storeIds); | |
| const directCards = directCardsForActors(storeIds); | |
| const actorIds = [...new Set([...storeIds, ...flowEndpointIds(directFlows)])]; | |
| applyActorClasses(actorIds, level); | |
| applyFlowClasses(directFlows, level); | |
| directCards.forEach((card) => { | |
| card.classList.add(level); | |
| card.closest(".step")?.classList.add(level); | |
| }); | |
| } | |
| function renderHighlights() { | |
| resetHighlightClasses(); | |
| app.classList.toggle("has-click-highlight", Boolean(selectedCardId || selectedActorId)); | |
| cards.forEach((card) => card.setAttribute("aria-pressed", String(card.dataset.cardId === selectedCardId))); | |
| actorsById.forEach((el, id) => el.setAttribute("aria-pressed", String(id === selectedActorId))); | |
| if (viewportStep && viewportStep.offsetParent !== null) { | |
| viewportStep.classList.add("in-view"); | |
| stepActorIds(viewportStep).forEach((id) => { | |
| actorsById.get(id)?.classList.add("active"); | |
| lifelinesById.get(id)?.classList.add("active"); | |
| }); | |
| } | |
| if (hoverCardId) applyCardFocus(cardsById.get(hoverCardId), "related"); | |
| if (hoverActorId) applyActorFocus(hoverActorId, "related"); | |
| const selectedCard = selectedCardId ? cardsById.get(selectedCardId) : null; | |
| if (selectedCard) applyCardFocus(selectedCard, "locked"); | |
| if (selectedActorId) applyActorFocus(selectedActorId, "locked"); | |
| if (app.classList.contains("focus-store")) { | |
| applyStoreFocus("locked"); | |
| } | |
| } | |
| function setHoverCard(cardId) { | |
| hoverCardId = cardId; | |
| renderHighlights(); | |
| } | |
| function setHoverActor(actorId) { | |
| hoverActorId = actorId; | |
| renderHighlights(); | |
| } | |
| function clearHover() { | |
| hoverCardId = null; | |
| hoverActorId = null; | |
| renderHighlights(); | |
| } | |
| function toggleSelectedCard(card) { | |
| const id = card?.dataset.cardId; | |
| selectedCardId = selectedCardId === id ? null : id; | |
| renderHighlights(); | |
| } | |
| function toggleSelectedActor(actorId) { | |
| selectedActorId = selectedActorId === actorId ? null : actorId; | |
| renderHighlights(); | |
| } | |
| function bindInteractions() { | |
| cards.forEach((card) => { | |
| card.tabIndex = 0; | |
| card.setAttribute("role", "button"); | |
| card.setAttribute("aria-pressed", "false"); | |
| card.addEventListener("mouseenter", () => setHoverCard(card.dataset.cardId)); | |
| card.addEventListener("focus", () => setHoverCard(card.dataset.cardId)); | |
| card.addEventListener("mouseleave", clearHover); | |
| card.addEventListener("blur", clearHover); | |
| card.addEventListener("click", () => toggleSelectedCard(card)); | |
| card.addEventListener("keydown", (event) => { | |
| if (event.key !== "Enter" && event.key !== " ") return; | |
| event.preventDefault(); | |
| toggleSelectedCard(card); | |
| }); | |
| }); | |
| actorsById.forEach((actorEl, actorId) => { | |
| actorEl.addEventListener("mouseenter", () => setHoverActor(actorId)); | |
| actorEl.addEventListener("focus", () => setHoverActor(actorId)); | |
| actorEl.addEventListener("mouseleave", clearHover); | |
| actorEl.addEventListener("blur", clearHover); | |
| actorEl.addEventListener("click", () => toggleSelectedActor(actorId)); | |
| actorEl.addEventListener("keydown", (event) => { | |
| if (event.key !== "Enter" && event.key !== " ") return; | |
| event.preventDefault(); | |
| toggleSelectedActor(actorId); | |
| }); | |
| }); | |
| } | |
| function updateActiveFromViewport() { | |
| const targetY = 238; | |
| const candidates = visibleSteps() | |
| .map((step) => { | |
| const rect = step.getBoundingClientRect(); | |
| return { | |
| step, | |
| visible: rect.bottom > 170 && rect.top < window.innerHeight - 60, | |
| distance: Math.abs(rect.top + rect.height * 0.5 - targetY) | |
| }; | |
| }) | |
| .filter((item) => item.visible) | |
| .sort((a, b) => a.distance - b.distance); | |
| viewportStep = candidates[0]?.step || visibleSteps()[0]; | |
| renderHighlights(); | |
| } | |
| function syncLayout() { | |
| alignFlows(); | |
| updateActiveFromViewport(); | |
| } | |
| let ticking = false; | |
| window.addEventListener("scroll", () => { | |
| if (ticking) return; | |
| ticking = true; | |
| requestAnimationFrame(() => { | |
| updateActiveFromViewport(); | |
| ticking = false; | |
| }); | |
| }, { passive: true }); | |
| window.addEventListener("resize", syncLayout); | |
| storeToggle.addEventListener("click", () => { | |
| storeToggle.classList.toggle("active"); | |
| storeToggle.setAttribute("aria-pressed", String(storeToggle.classList.contains("active"))); | |
| app.classList.toggle("focus-store", storeToggle.classList.contains("active")); | |
| renderHighlights(); | |
| }); | |
| markStoreRelated(); | |
| alignFlows(); | |
| bindInteractions(); | |
| updateActiveFromViewport(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment