issue-map 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,10 @@
2
2
  * 開發地圖的畫面。`issue-map.ts` 用 `Bun.build` 把這支打包成一段 script 塞進樣板,所以它
3
3
  * **只能碰瀏覽器的東西**,不能 import Bun 的 API。
4
4
  *
5
- * 形狀與純推導在 `issue-map-model.ts`:狀態的五個值、票的形狀、分群、關鍵路徑、線路圖的排版
6
- * 都在那邊,兩側共用同一份定義。文案全部在 `issue-map-i18n.ts`,這一支不寫死任何一句給人看的
7
- * 話。剩下的是 DOM、事件、收合與語言狀態。
5
+ * 標記全部在 `issue-map-view.ts`,而且建置時就已經用同一批函式畫過一次寫進檔案了——這一支只在
6
+ * 換語言、換篩選、選取、收合時重畫。所以沒有 JS 的環境看到的是同一份頁面,只是不能互動。
7
+ *
8
+ * 這裡剩下的是 DOM 與事件:讀寫 localStorage 的兩個偏好(語言、收合)、事件代理、選取狀態。
8
9
  */
9
10
 
10
11
  import {
@@ -17,23 +18,8 @@ import {
17
18
  setLocale,
18
19
  t,
19
20
  } from './issue-map-i18n.ts'
20
- import {
21
- layoutOf,
22
- MAP,
23
- type Edge,
24
- type Group,
25
- type Layout,
26
- type MapIssue,
27
- type NextStep,
28
- type Point,
29
- type Snapshot,
30
- STATUS_ORDER,
31
- type Status,
32
- } from './issue-map-model.ts'
33
-
34
- // ---- 資料 ----
35
-
36
- const SVG_NS = 'http://www.w3.org/2000/svg'
21
+ import { type Snapshot } from './issue-map-model.ts'
22
+ import { esc, type Filter, viewOf } from './issue-map-view.ts'
37
23
 
38
24
  /** 樣板保證這些節點存在。找不到就是樣板被改壞了,早點喊比畫出半張圖好。 */
39
25
  function pick(id: string): HTMLElement {
@@ -42,56 +28,17 @@ function pick(id: string): HTMLElement {
42
28
  return node
43
29
  }
44
30
 
45
- const raw = JSON.parse(pick('issue-map-data').textContent || '{}') as Partial<Snapshot>
46
- const issues: readonly MapIssue[] = raw.issues ?? []
47
- const byNumber = new Map(issues.map((issue) => [issue.number, issue]))
48
- const work = issues.filter((issue) => !issue.isParent)
49
- const criticalPath = raw.criticalPath ?? 0
50
- const repo = raw.repo ?? ''
51
-
52
- const counts = Object.fromEntries(
53
- STATUS_ORDER.map((status) => [status, work.filter((i) => i.status === status).length]),
54
- ) as Record<Status, number>
55
- const openCount = work.length - counts.done
56
-
57
- /** 誰擋著誰的反向索引。亮鏈與「關掉後解鎖」都用它。 */
58
- const unlocks = new Map<number, number[]>()
59
- for (const issue of issues) {
60
- for (const blocker of issue.blockedBy) {
61
- const dependents = unlocks.get(blocker) ?? []
62
- dependents.push(issue.number)
63
- unlocks.set(blocker, dependents)
64
- }
65
- }
66
-
67
- function byStatusThenNumber(a: MapIssue, b: MapIssue): number {
68
- return STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || a.number - b.number
69
- }
70
-
71
- const ESCAPES: Readonly<Record<string, string>> = {
72
- '&': '&amp;',
73
- '<': '&lt;',
74
- '>': '&gt;',
75
- '"': '&quot;',
76
- }
77
-
78
- function esc(text: string): string {
79
- return text.replace(/[&<>"]/g, (c) => ESCAPES[c] ?? c)
80
- }
31
+ const snapshot = JSON.parse(pick('issue-map-data').textContent || '{}') as Partial<Snapshot>
32
+ const view = viewOf(snapshot)
33
+ const repo = snapshot.repo ?? ''
81
34
 
82
- /**
83
- * 站名就是標題開頭。刻意不另設一個「短名」欄位——那要每張票靠人維護,而且會變成票名的第二個
84
- * 來源,改了標題不會跟著改。截到讀不通的時候,滑鼠停留與下面的清單都有完整標題。
85
- */
86
- const HEAD_CHARS = 7
87
-
88
- function head(title: string): string {
89
- return title.length > HEAD_CHARS ? `${title.slice(0, HEAD_CHARS)}…` : title
90
- }
35
+ const detail = pick('detail')
36
+ const rowsEl = pick('rows')
37
+ const groupsEl = pick('groups')
38
+ const tabsEl = pick('tabs')
91
39
 
92
- function issueAt(number: number): MapIssue | undefined {
93
- return byNumber.get(number)
94
- }
40
+ let selected: number | null = null
41
+ let filter: Filter = 'all'
95
42
 
96
43
  // ---- 語言 ----
97
44
 
@@ -113,29 +60,13 @@ function readLocale(): Locale {
113
60
  return DEFAULT_LOCALE
114
61
  }
115
62
 
116
- function statusLabel(status: Status): string {
117
- return t(`status.${status}`)
118
- }
119
-
120
- /** 日期一律照當下語言排。快照裡存的是 ISO 字串,格式化是畫面的事。 */
121
- function dateTime(iso: string): string {
122
- return new Date(iso).toLocaleString(locale(), { hour12: false })
123
- }
124
-
125
- function dateOnly(iso: string): string {
126
- return new Date(iso).toLocaleDateString(locale())
127
- }
128
-
129
63
  /** 語言選單只做一次;換語言是整頁重畫,選單自己不重建,不然焦點會掉。 */
130
64
  function mountLangPicker(): void {
131
65
  const picker = pick('lang')
132
66
  if (!(picker instanceof HTMLSelectElement)) throw new Error('#lang 不是 select')
133
- for (const option of LOCALES) {
134
- const node = document.createElement('option')
135
- node.value = option
136
- node.textContent = LOCALE_NAME[option]
137
- picker.appendChild(node)
138
- }
67
+ picker.innerHTML = LOCALES.map(
68
+ (option) => `<option value="${option}">${esc(LOCALE_NAME[option])}</option>`,
69
+ ).join('')
139
70
  picker.value = locale()
140
71
  picker.addEventListener('change', () => {
141
72
  if (!isLocale(picker.value)) return
@@ -157,9 +88,6 @@ function mountLangPicker(): void {
157
88
  */
158
89
  const COLLAPSE_KEY = `issue-map:collapsed:${repo}`
159
90
 
160
- /** 每顆收合鈕重畫自己的樣子。整頁重畫時要清掉,不然會留著指向已被移除的鈕的函式。 */
161
- const onFold: (() => void)[] = []
162
-
163
91
  function readFolded(): Set<string> {
164
92
  try {
165
93
  return new Set(JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '[]') as string[])
@@ -182,31 +110,13 @@ function setFolded(parent: number, shut: boolean): void {
182
110
  } catch {
183
111
  // 存不了就只在這一次有效,畫面照樣能開合。
184
112
  }
185
- for (const repaint of onFold) repaint()
186
113
  paintFolded()
187
114
  }
188
115
 
189
- /** 做一顆收合鈕。`parent` 是主票號碼,同一個號碼的鈕與清單列連動。 */
190
- function foldButton(parent: number): HTMLButtonElement {
191
- const button = document.createElement('button')
192
- button.type = 'button'
193
- button.className = 'fold'
194
- const paint = () => {
195
- const open = !isFolded(parent)
196
- button.setAttribute('aria-expanded', String(open))
197
- button.setAttribute(
198
- 'aria-label',
199
- open ? t('fold.collapse', { n: parent }) : t('fold.expand', { n: parent }),
200
- )
201
- button.textContent = open ? '−' : '+'
202
- }
203
- paint()
204
- onFold.push(paint)
205
- button.addEventListener('click', () => setFolded(parent, !isFolded(parent)))
206
- return button
207
- }
208
-
209
- /** 收合只在這裡畫一次:列、就地展開的細節、地圖本體,凡是掛了 data-parent 的都聽它。 */
116
+ /**
117
+ * 收合只在這裡畫一次:列、就地展開的細節、地圖本體,凡是掛了 data-parent 的都聽它。收合鈕的
118
+ * 樣子也在這裡補上——建置時畫出來的鈕是空的,因為那時還不知道這個瀏覽器記了什麼。
119
+ */
210
120
  function paintFolded(): void {
211
121
  for (const node of document.querySelectorAll<HTMLElement>('[data-parent]')) {
212
122
  node.hidden = isFolded(Number(node.dataset.parent))
@@ -214,490 +124,20 @@ function paintFolded(): void {
214
124
  for (const section of document.querySelectorAll<HTMLElement>('section.group[data-fold]')) {
215
125
  section.dataset.folded = String(isFolded(Number(section.dataset.fold)))
216
126
  }
217
- }
218
-
219
- // ---- 抬頭 ----
220
-
221
- function renderHeader(): void {
222
- const stats = pick('stats')
223
- stats.innerHTML = ''
224
- const tiles: { label: string; value: number; unit?: string; tone?: string }[] = [
225
- { label: t('stat.ready'), value: counts.ready, tone: 'ready' },
226
- { label: t('stat.active'), value: counts.active, tone: 'active' },
227
- { label: t('stat.blocked'), value: counts.blocked, unit: `/ ${openCount}`, tone: 'blocked' },
228
- { label: t('stat.triage'), value: counts.triage, tone: 'triage' },
229
- {
230
- label: t('stat.critical'),
231
- value: criticalPath,
232
- unit: t('stat.critical.unit', { n: criticalPath }),
233
- },
234
- ]
235
- for (const tile of tiles) {
236
- const box = document.createElement('div')
237
- box.className = 'stat'
238
- if (tile.tone) box.dataset.tone = tile.tone
239
- const unit = tile.unit ? `<small>${esc(tile.unit)}</small>` : ''
240
- box.innerHTML = `<b>${tile.value}${unit}</b><span>${esc(tile.label)}</span>`
241
- stats.appendChild(box)
242
- }
243
-
244
- // 抬頭全部照資料寫,換 repo 或換標籤字彙才不會留著上一個專案的字。
245
- const name = repo.split('/').pop()
246
- const heading = name ? t('title.withRepo', { repo: name }) : t('title.plain')
247
- pick('page-title').textContent = heading
248
- document.title = heading
249
- document.documentElement.lang = locale()
250
- pick('lang').setAttribute('aria-label', t('lang.label'))
251
- const when = raw.generatedAt ? ` · ${dateTime(raw.generatedAt)}` : ''
252
- pick('eyebrow').textContent = repo + when
253
-
254
- pick('lede').innerHTML = lede()
255
- renderFooter()
256
- }
257
-
258
- function renderFooter(): void {
259
- pick('foot-truth').textContent = t('foot.truth')
260
- const code = (command: string) => `<code>${esc(command)}</code>`
261
- pick('foot-refresh').innerHTML = t('foot.refresh', {
262
- build: code('bun run issue-map'),
263
- serve: code('bun run issue-map:serve'),
264
- })
265
-
266
- const vocab = raw.labels ?? { ready: [], unready: [] }
267
- // textContent:標籤名是 repo 給的字,不經過 innerHTML 就不必逃脫。
268
- pick('foot-config').textContent = vocab.ready.length
269
- ? t('foot.vocab', {
270
- ready: vocab.ready.join(t('join.or')),
271
- unready: vocab.unready.join(t('join.slash')),
272
- })
273
- : t('foot.noVocab')
274
- }
275
-
276
- /** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
277
- function lede(): string {
278
- if (!openCount) return t('lede.allDone')
279
- const frontline = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
280
- const parts = [t('lede.open', { n: openCount })]
281
- if (frontline.length) {
282
- const first = frontline
283
- .slice(0, 3)
284
- .map((i) => `<a href="${esc(i.url)}" target="_blank" rel="noopener">#${i.number}</a>`)
285
- .join(t('join.items'))
286
- parts.push(t('lede.ready', { n: frontline.length, issues: first }))
287
- } else {
288
- parts.push(t('lede.none'))
289
- }
290
- if (counts.blocked) parts.push(t('lede.blocked', { n: counts.blocked }))
291
- if (counts.triage) parts.push(t('lede.triage', { n: counts.triage }))
292
- if (criticalPath > 1) parts.push(t('lede.critical', { n: criticalPath }))
293
- return parts.join(' ')
294
- }
295
-
296
- // ---- 線路圖 ----
297
-
298
- /**
299
- * 同一條線就是一橫。跨線走「向右、轉、向下、轉、向右」,而垂直那一段刻意走在站與站之間的
300
- * 間隙裡——走中點的話會壓到中間那幾條線的站名。同一個終點有多條邊時各自錯開一點,不然它們
301
- * 會完全重疊成一條。
302
- */
303
- function railPath(a: Point, b: Point, nudge: number): string {
304
- const x1 = a.x + MAP.dot + 3
305
- const x2 = b.x - MAP.dot - 5
306
- if (a.y === b.y) return `M${x1} ${a.y} H${x2}`
307
- const gap = Math.max(x1 + MAP.bend + 2, b.x - MAP.step * 0.44 + nudge * 7)
308
- const turn = Math.min(gap, x2 - MAP.bend - 2)
309
- const dir = b.y > a.y ? 1 : -1
310
- return (
311
- `M${x1} ${a.y} H${turn - MAP.bend}` +
312
- ` Q${turn} ${a.y} ${turn} ${a.y + dir * MAP.bend}` +
313
- ` V${b.y - dir * MAP.bend}` +
314
- ` Q${turn} ${b.y} ${turn + MAP.bend} ${b.y}` +
315
- ` H${x2}`
316
- )
317
- }
318
-
319
- function svgTag(tag: string, attrs: Readonly<Record<string, string | number>>): string {
320
- const pairs = Object.entries(attrs)
321
- .map(([key, value]) => `${key}="${value}"`)
322
- .join(' ')
323
- return `<${tag} ${pairs}/>`
324
- }
325
-
326
- /**
327
- * 站點用形狀分辨,不只靠線條粗細——粗細在 7px 的圓點上分不出來。
328
- *
329
- * 圓是流程上的票(實心已關、空心等前置、靶心加光暈是現在可動),三角是有人在做,菱形是規格
330
- * 還沒定案。兩個例外各給一個形狀,掃一眼就分得開。
331
- */
332
- function stationShape(status: Status, q: Point): string {
333
- if (status === 'active') {
334
- // 往右指的三角形:這一站正在往下一站走。
335
- const r = MAP.dot + 1.5
336
- const d = `M${q.x - r * 0.72} ${q.y - r} L${q.x + r * 0.86} ${q.y} L${q.x - r * 0.72} ${q.y + r} Z`
337
- return svgTag('path', { class: 'mark', d })
338
- }
339
- if (status === 'triage') {
340
- const d = MAP.dot + 1
341
- return svgTag('rect', {
342
- class: 'mark',
343
- x: q.x - d,
344
- y: q.y - d,
345
- width: d * 2,
346
- height: d * 2,
347
- rx: 1,
348
- transform: `rotate(45 ${q.x} ${q.y})`,
349
- })
350
- }
351
- const dot = svgTag('circle', {
352
- class: 'mark',
353
- cx: q.x,
354
- cy: q.y,
355
- r: status === 'ready' ? MAP.dot + 2 : MAP.dot,
356
- })
357
- if (status !== 'ready') return dot
358
- return (
359
- svgTag('circle', { class: 'halo', cx: q.x, cy: q.y, r: MAP.dot + 7 }) +
360
- dot +
361
- svgTag('circle', { class: 'core', cx: q.x, cy: q.y, r: 3 })
362
- )
363
- }
364
-
365
- /** 一群票對應的一張圖。`members` 是票,`track` 是這一組的線色。 */
366
- type Shown = { group: Group; members: readonly MapIssue[]; track: string }
367
-
368
- function trackLabel(svg: SVGSVGElement, y: number, name: string, sub: string): void {
369
- const main = document.createElementNS(SVG_NS, 'text')
370
- main.setAttribute('class', 'tname')
371
- main.setAttribute('x', '8')
372
- main.setAttribute('y', String(y - 3))
373
- main.textContent = name
374
- svg.appendChild(main)
375
- const note = document.createElementNS(SVG_NS, 'text')
376
- note.setAttribute('class', 'tsub')
377
- note.setAttribute('x', '8')
378
- note.setAttribute('y', String(y + 12))
379
- note.textContent = sub
380
- svg.appendChild(note)
381
- }
382
-
383
- function railFor(edge: Edge, layout: Layout, nudge: number): SVGPathElement | null {
384
- const a = layout.xy.get(edge.from)
385
- const b = layout.xy.get(edge.to)
386
- if (!a || !b) return null
387
- const rail = document.createElementNS(SVG_NS, 'path')
388
- rail.setAttribute('class', 'edge')
389
- rail.setAttribute('d', railPath(a, b, nudge))
390
- rail.dataset.from = String(edge.from)
391
- rail.dataset.to = String(edge.to)
392
- rail.dataset.done = String(issueAt(edge.from)?.status === 'done')
393
- return rail
394
- }
395
-
396
- function stationFor(issue: MapIssue, q: Point): SVGAElement {
397
- const station = document.createElementNS(SVG_NS, 'a')
398
- station.setAttribute('class', 'node')
399
- station.setAttribute('href', issue.url)
400
- station.setAttribute('target', '_blank')
401
- station.setAttribute('rel', 'noopener')
402
- station.setAttribute(
403
- 'aria-label',
404
- t('node.aria', { n: issue.number, title: issue.title, status: statusLabel(issue.status) }),
405
- )
406
- station.dataset.status = issue.status
407
- station.dataset.number = String(issue.number)
408
- station.innerHTML =
409
- stationShape(issue.status, q) +
410
- `<text class="sid" x="${q.x}" y="${q.y - 15}" text-anchor="middle">#${issue.number}</text>` +
411
- `<text class="sdesc" x="${q.x}" y="${q.y + 24}" text-anchor="middle">${esc(head(issue.title))}</text>` +
412
- `<title>#${issue.number} ${esc(issue.title)}</title>`
413
- // 點站看詳細;要開 GitHub 用 ⌘/Ctrl 點,或詳細面板裡的連結。
414
- station.addEventListener('click', (ev) => {
415
- if (ev.metaKey || ev.ctrlKey || ev.shiftKey) return
416
- ev.preventDefault()
417
- ev.stopPropagation()
418
- // 再點同一站就取消,跟點空白處一樣——不然亮起來之後只剩 Esc 能收,那沒人找得到。
419
- if (selected === issue.number) clearSelection()
420
- else select(issue.number)
421
- })
422
- return station
423
- }
424
-
425
- function mapFor(shown: Shown): HTMLDivElement {
426
- const layout = layoutOf(shown.members)
427
- const wrap = document.createElement('div')
428
- wrap.className = 'map-wrap'
429
- const svg = document.createElementNS(SVG_NS, 'svg')
430
- svg.setAttribute('width', String(layout.width))
431
- svg.setAttribute('height', String(layout.height))
432
- svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`)
433
- svg.setAttribute('role', 'img')
434
- svg.style.setProperty('--track', shown.track)
435
-
436
- // 線名就是終點站的票號。單站線沒有「這條線在做什麼」可講,不標。
437
- layout.tracks.forEach((chain, index) => {
438
- const terminus = chain[chain.length - 1]
439
- if (chain.length < 2 || terminus === undefined) return
440
- trackLabel(
441
- svg,
442
- MAP.top + index * MAP.row,
443
- `→ #${terminus}`,
444
- t('map.stations', { n: chain.length }),
127
+ for (const button of document.querySelectorAll<HTMLElement>('.fold[data-fold-for]')) {
128
+ const parent = Number(button.dataset.foldFor)
129
+ const open = !isFolded(parent)
130
+ button.setAttribute('aria-expanded', String(open))
131
+ button.setAttribute(
132
+ 'aria-label',
133
+ open ? t('fold.collapse', { n: parent }) : t('fold.expand', { n: parent }),
445
134
  )
446
- })
447
- if (layout.islandRows) {
448
- trackLabel(svg, MAP.top + layout.islandFrom * MAP.row, t('map.islandName'), t('map.islandSub'))
449
- }
450
-
451
- const seenTo = new Map<number, number>()
452
- for (const edge of layout.edges) {
453
- const nth = seenTo.get(edge.to) ?? 0
454
- seenTo.set(edge.to, nth + 1)
455
- const rail = railFor(edge, layout, nth)
456
- if (rail) svg.appendChild(rail)
457
- }
458
-
459
- for (const member of shown.members) {
460
- const q = layout.xy.get(member.number)
461
- if (q) svg.appendChild(stationFor(member, q))
462
- }
463
-
464
- // 點圖上的空白處就取消亮線。
465
- svg.addEventListener('click', (ev) => {
466
- const target = ev.target
467
- if (target instanceof Element && target.closest('.node')) return
468
- clearSelection()
469
- })
470
- wrap.appendChild(svg)
471
- return wrap
472
- }
473
-
474
- /** 每張圖底下重複一份 key。圖可以收起來,key 跟著收,不會留一段沒有圖的說明。 */
475
- function mapKey(): HTMLDivElement {
476
- const key = document.createElement('div')
477
- key.className = 'map-key'
478
- const shapes: readonly [string, string][] = [
479
- ['k-ready', t('legend.ready')],
480
- ['k-active', t('legend.active')],
481
- ['k-blocked', t('legend.blocked')],
482
- ['k-triage', t('legend.triage')],
483
- ['k-done', t('legend.done')],
484
- ]
485
- key.innerHTML =
486
- shapes.map(([mark, label]) => `<span><i class="${mark}"></i>${esc(label)}</span>`).join('') +
487
- `<span>${esc(t('legend.solid'))}</span>` +
488
- `<span>${esc(t('legend.dashed'))}</span>`
489
- return key
490
- }
491
-
492
- const TRACK_COLOURS = ['--t1', '--t2', '--t3', '--t4']
493
-
494
- function renderGroups(): void {
495
- const groupsEl = pick('groups')
496
- groupsEl.innerHTML = ''
497
- const shownGroups: Shown[] = (raw.groups ?? []).map((group, index) => ({
498
- group,
499
- members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
500
- track: `var(${TRACK_COLOURS[index % TRACK_COLOURS.length]})`,
501
- }))
502
-
503
- for (const shown of shownGroups) {
504
- const section = document.createElement('section')
505
- section.className = 'group'
506
- section.style.setProperty('--track', shown.track)
507
-
508
- const header = document.createElement('div')
509
- header.className = 'group-head'
510
- header.innerHTML = `<h2>${esc(groupTitle(shown.group))}</h2><span class="sub">${groupSub(shown)}</span>`
511
- section.appendChild(header)
512
-
513
- const body = mapFor(shown)
514
- section.appendChild(body)
515
- section.appendChild(mapKey())
516
-
517
- // 主票的子票群可以收起來。獨立票沒有主票,不給收合。
518
- const parent = shown.group.parent
519
- if (parent !== null) {
520
- header.insertBefore(foldButton(parent), header.firstChild)
521
- section.dataset.fold = String(parent)
522
- body.dataset.parent = String(parent)
523
- }
524
- groupsEl.appendChild(section)
525
- }
526
- }
527
-
528
- /** 一群票的標題。主票那一群用主票標題(真資料),其他三種是頁面自己的分類。 */
529
- function groupTitle(group: Group): string {
530
- switch (group.name.kind) {
531
- case 'spec':
532
- return group.name.title
533
- case 'orphan':
534
- return t('group.orphan', { n: group.name.parent })
535
- case 'linked':
536
- return t('group.linked')
537
- case 'island':
538
- return t('group.island')
539
- }
540
- }
541
-
542
- function groupSub(shown: Shown): string {
543
- const parent = shown.group.parent
544
- // 「其他依賴鏈」與「獨立票」都沒有主票,但只有後者互不阻擋——同一句話蓋兩種群會說謊。
545
- if (shown.group.name.kind === 'linked') return esc(t('group.linkedSub'))
546
- if (parent === null) return esc(t('group.islandSub'))
547
- const done = shown.members.filter((m) => m.status === 'done').length
548
- const spec = issueAt(parent)
549
- const specLink = spec
550
- ? `<a href="${esc(spec.url)}" target="_blank" rel="noopener">${esc(
551
- t('group.spec', { n: parent }),
552
- )}</a> · `
553
- : ''
554
- return specLink + esc(t('group.progress', { done, total: shown.members.length }))
555
- }
556
-
557
- // ---- 詳細 ----
558
-
559
- /**
560
- * 等誰那一欄只列前幾張。一張主票可以等十幾張子票,全列出來會把整欄撐到把標題壓扁——完整
561
- * 清單放 title,看得到也不爆版。
562
- */
563
- const BLOCKERS_SHOWN = 4
564
-
565
- function hash(n: number): string {
566
- return `#${n}`
567
- }
568
-
569
- function blockers(list: readonly number[]): string {
570
- if (!list.length) return '—'
571
- if (list.length <= BLOCKERS_SHOWN) return list.map(hash).join(' ')
572
- const rest = list.length - BLOCKERS_SHOWN
573
- return `${list.slice(0, BLOCKERS_SHOWN).map(hash).join(' ')} <span class="more">+${rest}</span>`
574
- }
575
-
576
- function link(n: number): string {
577
- const issue = issueAt(n)
578
- return issue ? `<a href="${esc(issue.url)}" target="_blank" rel="noopener">#${n}</a>` : `#${n}`
579
- }
580
-
581
- function pill(issue: MapIssue): string {
582
- return `<span class="pill" data-status="${issue.status}">${esc(statusLabel(issue.status))}</span>`
583
- }
584
-
585
- /** 下一步的句子。模型只說是哪一種,話在這裡才組出來。 */
586
- function stepText(step: NextStep): string {
587
- switch (step.kind) {
588
- case 'none':
589
- return '—'
590
- case 'command':
591
- return step.command
592
- case 'manual':
593
- return t('step.manual')
594
- case 'active':
595
- return step.who.length ? step.who.join(t('join.slash')) : t('step.active')
596
- case 'waitIssues':
597
- return t('step.waitIssues', { issues: step.issues.map(hash).join(' ') })
598
- case 'waitChildren':
599
- return t('step.waitChildren', { n: step.count })
600
- case 'parentReady':
601
- return t('step.parentReady')
602
- }
603
- }
604
-
605
- /**
606
- * 是指令的時候做成按鈕,按了把「指令 + 票號」整句複製走——真正要貼進去的是那一整句,只顯示
607
- * 指令的話還得自己補票號。其他的(接手的人、等哪幾張)就純文字。
608
- */
609
- function stepCell(issue: MapIssue): string {
610
- const step = issue.nextStep
611
- const text = stepText(step)
612
- if (step.kind !== 'command') return `<span class="next">${esc(text)}</span>`
613
- const command = `${step.command} #${issue.number}`
614
- const title = esc(t('copy.title', { command }))
615
- return (
616
- `<button type="button" class="copy" data-copy="${esc(command)}" title="${title}">` +
617
- `<span class="copy-text">${esc(step.command)}</span></button>`
618
- )
619
- }
620
-
621
- /** 一張票的細節。上面的面板與清單裡展開的那一列共用同一份標記。 */
622
- function detailHTML(number: number): string {
623
- const issue = issueAt(number)
624
- if (!issue) return ''
625
- const settled = issue.blockedBy.filter((b) => !issue.waitingFor.includes(b))
626
- const opens = (unlocks.get(number) ?? []).filter((n) => issueAt(n)?.status !== 'done')
627
- const closed = issue.closedAt ? esc(t('detail.closedAt', { date: dateOnly(issue.closedAt) })) : ''
628
- const rows: [string, string][] = [
629
- [t('detail.status'), pill(issue) + closed],
630
- [t('detail.next'), stepCell(issue)],
631
- ]
632
- if (issue.author) rows.push([t('detail.author'), esc(issue.author)])
633
- if (issue.parent !== null) rows.push([t('detail.parent'), link(issue.parent)])
634
- if (issue.waitingFor.length) {
635
- rows.push([t('detail.waiting'), issue.waitingFor.map(link).join(' ')])
636
- }
637
- if (settled.length) rows.push([t('detail.settled'), settled.map(link).join(' ')])
638
- if (opens.length) rows.push([t('detail.unlocks'), opens.map(link).join(' ')])
639
- if (issue.labels.length) {
640
- rows.push([
641
- t('detail.labels'),
642
- issue.labels.map((l) => `<span class="label">${esc(l)}</span>`).join(''),
643
- ])
644
- }
645
- if (issue.assignees.length) {
646
- rows.push([t('detail.assignees'), esc(issue.assignees.join(', '))])
647
- }
648
- return (
649
- `<h3><span class="num">#${issue.number}</span>${esc(issue.title)}</h3>` +
650
- `<a class="open" href="${esc(issue.url)}" target="_blank" rel="noopener">${esc(
651
- t('detail.open'),
652
- )}</a>` +
653
- `<dl class="rows">${rows.map(([dt, dd]) => `<dt>${esc(dt)}</dt><dd>${dd}</dd>`).join('')}</dl>`
654
- )
655
- }
656
-
657
- const detail = pick('detail')
658
-
659
- function renderDetail(number: number | undefined): void {
660
- const issue = number === undefined ? undefined : issueAt(number)
661
- if (!issue) {
662
- detail.dataset.status = ''
663
- detail.innerHTML = `<h3>${esc(t('detail.empty'))}</h3>`
664
- return
135
+ button.textContent = open ? '−' : '+'
665
136
  }
666
- detail.dataset.status = issue.status
667
- detail.innerHTML = detailHTML(issue.number)
668
137
  }
669
138
 
670
139
  // ---- 選取 ----
671
140
 
672
- let selected: number | null = null
673
-
674
- /** 一張票的整條上下游,用來在選取時把鏈亮起來。 */
675
- function chainOf(start: number): Set<number> {
676
- const out = new Set([start])
677
- const walks: ((n: number) => readonly number[])[] = [
678
- (n) => issueAt(n)?.blockedBy ?? [],
679
- (n) => unlocks.get(n) ?? [],
680
- ]
681
- for (const neighboursOf of walks) {
682
- const stack = [start]
683
- while (stack.length) {
684
- const at = stack.pop()
685
- if (at === undefined) break
686
- for (const next of neighboursOf(at)) {
687
- if (out.has(next)) continue
688
- out.add(next)
689
- stack.push(next)
690
- }
691
- }
692
- }
693
- return out
694
- }
695
-
696
- function defaultPick(): number | undefined {
697
- const first = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)[0]
698
- return (first ?? issues[0])?.number
699
- }
700
-
701
141
  /**
702
142
  * 選一張票。再點同一張不會取消——在清單上你往往只是想再看一次,取消掉反而要重新找。要清掉
703
143
  * 按 Esc。
@@ -714,15 +154,15 @@ function clearSelection(): void {
714
154
  }
715
155
 
716
156
  function paintSelection(): void {
717
- const lit = selected === null ? null : chainOf(selected)
157
+ const lit = selected === null ? null : view.chainOf(selected)
718
158
  for (const svg of document.querySelectorAll<SVGSVGElement>('svg')) {
719
159
  svg.classList.toggle('has-selection', selected !== null)
720
- for (const node of svg.querySelectorAll<SVGAElement>('.node')) {
160
+ for (const node of svg.querySelectorAll<SVGElement>('.node')) {
721
161
  const number = Number(node.dataset.number)
722
162
  node.classList.toggle('lit', lit !== null && lit.has(number))
723
163
  node.classList.toggle('selected', number === selected)
724
164
  }
725
- for (const edge of svg.querySelectorAll<SVGPathElement>('.edge')) {
165
+ for (const edge of svg.querySelectorAll<SVGElement>('.edge')) {
726
166
  const from = Number(edge.dataset.from)
727
167
  const to = Number(edge.dataset.to)
728
168
  edge.classList.toggle('lit', lit !== null && lit.has(from) && lit.has(to))
@@ -731,117 +171,13 @@ function paintSelection(): void {
731
171
  for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-number]')) {
732
172
  row.classList.toggle('selected', Number(row.dataset.number) === selected)
733
173
  }
734
- renderDetail(selected ?? defaultPick())
735
- }
736
-
737
- // ---- 清單 ----
738
-
739
- type Row = { issue: MapIssue; parent: number | null; hasKids: boolean }
740
-
741
- const rowsEl = pick('rows')
742
- let filter: Status | 'all' = 'all'
743
-
744
- /** 主票在前、它的子票跟在後面。篩選時主票只要有子票入選就留著當標頭。 */
745
- function rowOrder(): Row[] {
746
- const pass = new Set(
747
- issues.filter((i) => filter === 'all' || i.status === filter).map((i) => i.number),
748
- )
749
- const kids = new Map<number, MapIssue[]>()
750
- const roots: MapIssue[] = []
751
- for (const issue of issues) {
752
- const parent = issue.parent !== null && byNumber.has(issue.parent) ? issue.parent : null
753
- if (parent === null) {
754
- if (pass.has(issue.number)) roots.push(issue)
755
- continue
756
- }
757
- if (!pass.has(issue.number)) continue
758
- const siblings = kids.get(parent) ?? []
759
- siblings.push(issue)
760
- kids.set(parent, siblings)
761
- }
762
- // 有子票入選但自己沒入選的主票,仍要出現,否則子票就沒了歸屬。
763
- for (const parent of kids.keys()) {
764
- const issue = issueAt(parent)
765
- if (issue && !roots.includes(issue)) roots.push(issue)
766
- }
767
-
768
- const rows: Row[] = []
769
- for (const root of roots.sort(byStatusThenNumber)) {
770
- const children = (kids.get(root.number) ?? []).sort(byStatusThenNumber)
771
- rows.push({ issue: root, parent: null, hasKids: children.length > 0 })
772
- for (const child of children) rows.push({ issue: child, parent: root.number, hasKids: false })
773
- }
774
- return rows
775
- }
776
-
777
- function rowHTML(row: Row): string {
778
- const issue = row.issue
779
- const parent = row.parent === null ? '' : ` data-parent="${row.parent}"`
780
- const kids = row.hasKids ? ' data-haskids="true"' : ''
781
- const slot = row.hasKids ? '<span class="fold-slot"></span>' : ''
782
- const waitsTitle =
783
- issue.waitingFor.length > BLOCKERS_SHOWN
784
- ? ` title="${esc(issue.waitingFor.map(hash).join(' '))}"`
785
- : ''
786
- const waits = issue.waitingFor.length
787
- ? `<span class="waits"${waitsTitle}>${t('row.waits', {
788
- list: blockers(issue.waitingFor),
789
- })}</span>`
790
- : ''
791
- return (
792
- `<div class="row" tabindex="0" data-status="${issue.status}" data-number="${issue.number}"${parent}${kids}>` +
793
- `<div class="id">${slot}` +
794
- `<a class="no" href="${esc(issue.url)}" target="_blank" rel="noopener">#${issue.number}</a>` +
795
- `<span class="who">${issue.author ? esc(issue.author) : '—'}</span></div>` +
796
- `<div class="what"><span class="title">${esc(issue.title)}</span>${waits}</div>` +
797
- `<div class="step">${stepCell(issue)}</div>` +
798
- `<div class="gate">${pill(issue)}</div>` +
799
- '</div>'
800
- )
801
- }
802
-
803
- function renderRows(): void {
804
- const rows = rowOrder()
805
- const shown = rows.filter((r) => !r.issue.isParent).length
806
- pick('list-title').textContent = t('list.title')
807
- pick('list-sub').textContent = t('list.count', { n: shown })
808
- rowsEl.innerHTML = rows.map(rowHTML).join('')
809
- // 主票的收合鈕:innerHTML 重建過,鈕要重新放進去。
810
- for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-haskids="true"]')) {
811
- row.querySelector('.fold-slot')?.appendChild(foldButton(Number(row.dataset.number)))
812
- }
174
+ renderDetail(selected ?? view.defaultPick())
813
175
  }
814
176
 
815
- function renderTabs(): void {
816
- const tabs = pick('tabs')
817
- tabs.innerHTML = ''
818
- tabs.setAttribute('aria-label', t('tabs.aria'))
819
- const defs: [Status | 'all', string, number][] = [
820
- ['all', t('tabs.all'), work.length],
821
- ...STATUS_ORDER.map((s) => [s, statusLabel(s), counts[s]] as [Status, string, number]),
822
- ]
823
- for (const [key, label, count] of defs) {
824
- const tab = document.createElement('button')
825
- tab.type = 'button'
826
- tab.className = 'tab'
827
- tab.dataset.filter = key
828
- tab.setAttribute('aria-pressed', String(key === filter))
829
- tab.innerHTML = `${esc(label)}<span class="n">${count}</span>`
830
- tab.addEventListener('click', () => {
831
- filter = key
832
- for (const other of tabs.querySelectorAll<HTMLElement>('.tab')) {
833
- other.setAttribute('aria-pressed', String(other.dataset.filter === filter))
834
- }
835
- renderRows()
836
- paintSelection()
837
- const back =
838
- selected === null
839
- ? null
840
- : rowsEl.querySelector<HTMLElement>(`.row[data-number="${selected}"]`)
841
- if (back && selected !== null) openExpander(back, selected)
842
- })
843
- tabs.appendChild(tab)
844
- }
177
+ function renderDetail(number: number | undefined): void {
178
+ const panel = view.detailPanelHTML(number)
179
+ detail.dataset.status = panel.status
180
+ detail.innerHTML = panel.html
845
181
  }
846
182
 
847
183
  // ---- 就地展開 ----
@@ -856,7 +192,7 @@ function closeExpander(): void {
856
192
 
857
193
  function openExpander(row: HTMLElement, number: number): void {
858
194
  closeExpander()
859
- const issue = issueAt(number)
195
+ const issue = view.issueAt(number)
860
196
  if (!issue) return
861
197
  const panel = document.createElement('div')
862
198
  panel.className = 'detail expand'
@@ -864,7 +200,7 @@ function openExpander(row: HTMLElement, number: number): void {
864
200
  panel.dataset.forNumber = String(number)
865
201
  // 掛上 data-parent,主票收起來時 paintFolded 會一起把它藏掉。
866
202
  if (row.dataset.parent) panel.dataset.parent = row.dataset.parent
867
- panel.innerHTML = detailHTML(number)
203
+ panel.innerHTML = view.detailHTML(number)
868
204
  row.insertAdjacentElement('afterend', panel)
869
205
  paintFolded()
870
206
  }
@@ -933,10 +269,18 @@ function elementAt(ev: Event): Element | null {
933
269
  return ev.target instanceof Element ? ev.target : null
934
270
  }
935
271
 
936
- // 一個委派聽事件,換分頁不用重掛。
937
- rowsEl.addEventListener('click', (ev) => {
272
+ // 全部走委派:標記是建置時畫好的,重畫也只是換 innerHTML,沒有可以逐一掛監聽的時機。
273
+ document.addEventListener('click', (ev) => {
938
274
  const target = elementAt(ev)
939
275
  if (!target) return
276
+
277
+ const fold = target.closest<HTMLElement>('.fold[data-fold-for]')
278
+ if (fold) {
279
+ const parent = Number(fold.dataset.foldFor)
280
+ setFolded(parent, !isFolded(parent))
281
+ return
282
+ }
283
+
940
284
  const copy = target.closest<HTMLElement>('.copy')
941
285
  if (copy) {
942
286
  // 複製是自己一件事,不要順手把那一列也選起來。
@@ -944,7 +288,39 @@ rowsEl.addEventListener('click', (ev) => {
944
288
  handleCopy(copy)
945
289
  return
946
290
  }
947
- if (target.closest('a') || target.closest('.fold')) return
291
+
292
+ const tab = target.closest<HTMLElement>('.tab[data-filter]')
293
+ if (tab) {
294
+ filter = (tab.dataset.filter ?? 'all') as Filter
295
+ renderRows()
296
+ paintSelection()
297
+ const back =
298
+ selected === null
299
+ ? null
300
+ : rowsEl.querySelector<HTMLElement>(`.row[data-number="${selected}"]`)
301
+ if (back && selected !== null) openExpander(back, selected)
302
+ return
303
+ }
304
+
305
+ // 點站看詳細;要開 GitHub 用 ⌘/Ctrl 點,或詳細面板裡的連結。
306
+ const node = target.closest<HTMLElement>('.node[data-number]')
307
+ if (node) {
308
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey) return
309
+ ev.preventDefault()
310
+ const number = Number(node.dataset.number)
311
+ // 再點同一站就取消,跟點空白處一樣——不然亮起來之後只剩 Esc 能收,那沒人找得到。
312
+ if (selected === number) clearSelection()
313
+ else select(number)
314
+ return
315
+ }
316
+
317
+ // 點圖上的空白處就取消亮線。
318
+ if (target.closest('.map-wrap')) {
319
+ clearSelection()
320
+ return
321
+ }
322
+
323
+ if (target.closest('a')) return
948
324
  const row = target.closest<HTMLElement>('.row[data-number]')
949
325
  if (row) pickRow(row)
950
326
  })
@@ -969,19 +345,39 @@ document.addEventListener('keydown', (ev) => {
969
345
  if (ev.key === 'Escape') clearSelection()
970
346
  })
971
347
 
972
- // ---- 起動 ----
348
+ // ---- 重畫 ----
349
+
350
+ function renderRows(): void {
351
+ const rows = view.rowsHTML(filter)
352
+ pick('list-title').textContent = t('list.title')
353
+ pick('list-sub').textContent = t('list.count', { n: rows.shown })
354
+ rowsEl.innerHTML = rows.html
355
+ tabsEl.innerHTML = view.tabsHTML(filter)
356
+ paintFolded()
357
+ }
973
358
 
974
359
  /**
975
360
  * 整頁重畫。換語言就是走這裡——每一段都會先清掉自己那一塊,所以重畫一次不會留下上一種語言的
976
361
  * 殘骸。選取與收合是狀態不是文字,重畫時保留。
977
362
  */
978
363
  function render(): void {
979
- onFold.length = 0
980
- renderHeader()
981
- renderGroups()
982
- renderTabs()
364
+ const heading = view.title()
365
+ pick('page-title').textContent = heading
366
+ document.title = heading
367
+ document.documentElement.lang = locale()
368
+ pick('lang').setAttribute('aria-label', t('lang.label'))
369
+ pick('eyebrow').textContent = view.eyebrow()
370
+ pick('lede').innerHTML = view.lede()
371
+ pick('stats').innerHTML = view.statsHTML()
372
+
373
+ const foot = view.footerHTML()
374
+ pick('foot-truth').textContent = foot.truth
375
+ pick('foot-refresh').innerHTML = foot.refresh
376
+ pick('foot-config').textContent = foot.config
377
+
378
+ groupsEl.innerHTML = view.groupsHTML()
379
+ tabsEl.setAttribute('aria-label', t('tabs.aria'))
983
380
  renderRows()
984
- paintFolded()
985
381
  // paintSelection 收尾會畫詳細,沒有選取時它自己退回預設那一張。
986
382
  paintSelection()
987
383
  }