issue-map 0.3.1 → 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.
- package/README.ja.md +82 -109
- package/README.md +88 -112
- package/README.zh-CN.md +74 -78
- package/README.zh-TW.md +74 -78
- package/package.json +5 -4
- package/scripts/issue-map-i18n.ts +11 -5
- package/scripts/issue-map-model.ts +11 -4
- package/scripts/issue-map-page.ts +109 -713
- package/scripts/issue-map-serve.ts +16 -17
- package/scripts/issue-map-view.ts +650 -0
- package/scripts/issue-map.html +31 -5
- package/scripts/issue-map.ts +109 -44
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* 開發地圖的畫面。`issue-map.ts` 用 `Bun.build` 把這支打包成一段 script 塞進樣板,所以它
|
|
3
3
|
* **只能碰瀏覽器的東西**,不能 import Bun 的 API。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
46
|
-
const
|
|
47
|
-
const
|
|
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
|
-
'&': '&',
|
|
73
|
-
'<': '<',
|
|
74
|
-
'>': '>',
|
|
75
|
-
'"': '"',
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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<
|
|
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<
|
|
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
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
}
|