issue-map 0.3.1 → 0.5.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 +87 -111
- package/README.md +94 -115
- package/README.zh-CN.md +80 -81
- package/README.zh-TW.md +80 -81
- package/package.json +10 -15
- package/{scripts → src}/issue-map-i18n.ts +11 -5
- package/{scripts → src}/issue-map-model.ts +55 -26
- package/src/issue-map-page.ts +411 -0
- package/{scripts → src}/issue-map-serve.ts +33 -19
- package/src/issue-map-view.ts +694 -0
- package/{scripts → src}/issue-map.html +33 -6
- package/src/issue-map.ts +657 -0
- package/scripts/issue-map-page.ts +0 -991
- package/scripts/issue-map.ts +0 -446
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把快照畫成 HTML 字串。**沒有 Bun、沒有 DOM**——建置那一側(`issue-map.ts`)拿它把頁面預先
|
|
3
|
+
* 畫好寫進檔案,畫面那一側(`issue-map-page.ts`)拿同一批函式在換語言/換篩選時重畫。
|
|
4
|
+
*
|
|
5
|
+
* 一份標記只有一個產生處:兩邊畫出來的東西必然一致,不會有「靜態看到一種、互動後變另一種」。
|
|
6
|
+
* 也因此這一支不能碰 `document`——互動(點擊、收合、選取)全部留在畫面那一側用事件代理處理。
|
|
7
|
+
*
|
|
8
|
+
* 文案一律走 `issue-map-i18n.ts` 的 `t()`,它讀的是當下語言。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { LOCALE_NAME, LOCALES, locale, t } from './issue-map-i18n.ts'
|
|
12
|
+
import {
|
|
13
|
+
type Edge,
|
|
14
|
+
edgesWithin,
|
|
15
|
+
type Group,
|
|
16
|
+
layoutOf,
|
|
17
|
+
type Layout,
|
|
18
|
+
MAP,
|
|
19
|
+
type MapIssue,
|
|
20
|
+
type NextStep,
|
|
21
|
+
type Point,
|
|
22
|
+
type Snapshot,
|
|
23
|
+
STATUS_ORDER,
|
|
24
|
+
type Status,
|
|
25
|
+
} from './issue-map-model.ts'
|
|
26
|
+
|
|
27
|
+
const ESCAPES: Readonly<Record<string, string>> = {
|
|
28
|
+
'&': '&',
|
|
29
|
+
'<': '<',
|
|
30
|
+
'>': '>',
|
|
31
|
+
'"': '"',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function esc(text: string): string {
|
|
35
|
+
return text.replace(/[&<>"]/g, (c) => ESCAPES[c] ?? c)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 語言選單的選項。建置時先畫一份進樣板,畫面那一側重建選單時用的是同一支——這一支檔案是標記
|
|
40
|
+
* 的唯一產生處,選單也不例外。
|
|
41
|
+
*/
|
|
42
|
+
export function langOptionsHTML(): string {
|
|
43
|
+
return LOCALES.map((l) => `<option value="${l}">${esc(LOCALE_NAME[l])}</option>`).join('')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 站名就是標題開頭。刻意不另設一個「短名」欄位——那要每張票靠人維護,而且會變成票名的第二個
|
|
48
|
+
* 來源,改了標題不會跟著改。截到讀不通的時候,滑鼠停留與下面的清單都有完整標題。
|
|
49
|
+
*/
|
|
50
|
+
const HEAD_CHARS = 7
|
|
51
|
+
|
|
52
|
+
/** 等誰那一欄只列前幾張,完整清單放 title——一張主票可以等十幾張子票。 */
|
|
53
|
+
const BLOCKERS_SHOWN = 4
|
|
54
|
+
|
|
55
|
+
const TRACK_COLOURS = ['--t1', '--t2', '--t3', '--t4']
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 最多畫幾張圖。
|
|
59
|
+
*
|
|
60
|
+
* 一組沒有阻擋關係的票畫出來只是一片點陣,而 `grafana/grafana` 那種規模是 108 組裡 103 組都
|
|
61
|
+
* 這樣——那 103 張點陣沒有人會讀,卻佔掉產出的 2MB。有線路的一定畫,其餘補到這個數為止,剩下
|
|
62
|
+
* 的只留標頭。票照樣在下面的清單裡,一張都不會少。
|
|
63
|
+
*/
|
|
64
|
+
const MAX_MAPS = 20
|
|
65
|
+
|
|
66
|
+
/** 一群票對應的一張圖。`members` 是票,`track` 是這一組的線色。 */
|
|
67
|
+
export type Shown = { group: Group; members: readonly MapIssue[]; track: string }
|
|
68
|
+
|
|
69
|
+
/** 清單上的一列。`parent` 是直屬主票,`depth` 是它在 parent 樹裡的層數(頂層是 0)。 */
|
|
70
|
+
export type Row = {
|
|
71
|
+
issue: MapIssue
|
|
72
|
+
parent: number | null
|
|
73
|
+
depth: number
|
|
74
|
+
hasKids: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type Filter = Status | 'all'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 一份快照畫得出來的所有東西。
|
|
81
|
+
*
|
|
82
|
+
* 先算一次衍生資料(計數、反向索引),再把畫的函式掛上去——兩側都只要 `viewOf(snapshot)` 一次
|
|
83
|
+
* 就能重複畫。
|
|
84
|
+
*/
|
|
85
|
+
export function viewOf(snapshot: Snapshot) {
|
|
86
|
+
const issues: readonly MapIssue[] = snapshot.issues
|
|
87
|
+
const byNumber = new Map(issues.map((issue) => [issue.number, issue]))
|
|
88
|
+
const work = issues.filter((issue) => !issue.isParent)
|
|
89
|
+
const criticalPath = snapshot.criticalPath
|
|
90
|
+
const repo = snapshot.repo
|
|
91
|
+
|
|
92
|
+
const counts = Object.fromEntries(
|
|
93
|
+
STATUS_ORDER.map((status) => [status, work.filter((i) => i.status === status).length]),
|
|
94
|
+
) as Record<Status, number>
|
|
95
|
+
const openCount = work.length - counts.done
|
|
96
|
+
|
|
97
|
+
/** 誰擋著誰的反向索引。亮鏈與「關掉後解鎖」都用它。 */
|
|
98
|
+
const unlocks = new Map<number, number[]>()
|
|
99
|
+
for (const issue of issues) {
|
|
100
|
+
for (const blocker of issue.blockedBy) {
|
|
101
|
+
const dependents = unlocks.get(blocker) ?? []
|
|
102
|
+
dependents.push(issue.number)
|
|
103
|
+
unlocks.set(blocker, dependents)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const issueAt = (number: number): MapIssue | undefined => byNumber.get(number)
|
|
108
|
+
|
|
109
|
+
const byStatusThenNumber = (a: MapIssue, b: MapIssue): number =>
|
|
110
|
+
STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || a.number - b.number
|
|
111
|
+
|
|
112
|
+
const statusLabel = (status: Status): string => t(`status.${status}`)
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 現在可動的票,照清單的順序。導言與詳細面板的預設那一張都讀它——兩邊各算一次的話,導言說
|
|
116
|
+
* 「最前面是 #N」而面板開的卻是別張。
|
|
117
|
+
*/
|
|
118
|
+
const frontline = (): MapIssue[] =>
|
|
119
|
+
work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
|
|
120
|
+
|
|
121
|
+
/** 日期一律照當下語言排。快照裡存的是 ISO 字串,格式化是畫面的事。 */
|
|
122
|
+
const dateTime = (iso: string): string =>
|
|
123
|
+
new Date(iso).toLocaleString(locale(), { hour12: false })
|
|
124
|
+
const dateOnly = (iso: string): string => new Date(iso).toLocaleDateString(locale())
|
|
125
|
+
|
|
126
|
+
const head = (title: string): string =>
|
|
127
|
+
title.length > HEAD_CHARS ? `${title.slice(0, HEAD_CHARS)}…` : title
|
|
128
|
+
|
|
129
|
+
const hash = (n: number): string => `#${n}`
|
|
130
|
+
|
|
131
|
+
const link = (n: number): string => {
|
|
132
|
+
const issue = issueAt(n)
|
|
133
|
+
return issue ? `<a href="${esc(issue.url)}" target="_blank" rel="noopener">#${n}</a>` : `#${n}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pill = (issue: MapIssue): string =>
|
|
137
|
+
`<span class="pill" data-status="${issue.status}">${esc(statusLabel(issue.status))}</span>`
|
|
138
|
+
|
|
139
|
+
const blockers = (list: readonly number[]): string => {
|
|
140
|
+
if (!list.length) return '—'
|
|
141
|
+
if (list.length <= BLOCKERS_SHOWN) return list.map(hash).join(' ')
|
|
142
|
+
const rest = list.length - BLOCKERS_SHOWN
|
|
143
|
+
return `${list.slice(0, BLOCKERS_SHOWN).map(hash).join(' ')} <span class="more">+${rest}</span>`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 下一步的句子。模型只說是哪一種,話在這裡才組出來。 */
|
|
147
|
+
const stepText = (step: NextStep): string => {
|
|
148
|
+
switch (step.kind) {
|
|
149
|
+
case 'none':
|
|
150
|
+
return '—'
|
|
151
|
+
case 'command':
|
|
152
|
+
return step.command
|
|
153
|
+
case 'manual':
|
|
154
|
+
return t('step.manual')
|
|
155
|
+
case 'active':
|
|
156
|
+
return step.who.length ? step.who.join(t('join.slash')) : t('step.active')
|
|
157
|
+
case 'waitIssues':
|
|
158
|
+
return t('step.waitIssues', { issues: step.issues.map(hash).join(' ') })
|
|
159
|
+
case 'waitChildren':
|
|
160
|
+
return t('step.waitChildren', { n: step.count })
|
|
161
|
+
case 'parentReady':
|
|
162
|
+
return t('step.parentReady')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 是指令的時候做成按鈕,按了把「指令 + 票號」整句複製走——真正要貼進去的是那一整句,只顯示
|
|
168
|
+
* 指令的話還得自己補票號。其他的(接手的人、等哪幾張)就純文字。
|
|
169
|
+
*/
|
|
170
|
+
const stepCell = (issue: MapIssue): string => {
|
|
171
|
+
const step = issue.nextStep
|
|
172
|
+
if (step.kind !== 'command') return `<span class="next">${esc(stepText(step))}</span>`
|
|
173
|
+
const command = `${step.command} #${issue.number}`
|
|
174
|
+
const title = esc(t('copy.title', { command }))
|
|
175
|
+
return (
|
|
176
|
+
`<button type="button" class="copy" data-copy="${esc(command)}" title="${title}">` +
|
|
177
|
+
`<span class="copy-text">${esc(step.command)}</span></button>`
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- 抬頭 ----
|
|
182
|
+
|
|
183
|
+
const title = (): string => {
|
|
184
|
+
const name = repo.split('/').pop()
|
|
185
|
+
return name ? t('title.withRepo', { repo: name }) : t('title.plain')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const eyebrow = (): string =>
|
|
189
|
+
repo + (snapshot.generatedAt ? ` · ${dateTime(snapshot.generatedAt)}` : '')
|
|
190
|
+
|
|
191
|
+
/** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
|
|
192
|
+
const lede = (): string => {
|
|
193
|
+
if (!openCount) return t('lede.allDone')
|
|
194
|
+
const ready = frontline()
|
|
195
|
+
const parts = [t('lede.open', { n: openCount })]
|
|
196
|
+
if (ready.length) {
|
|
197
|
+
const first = ready
|
|
198
|
+
.slice(0, 3)
|
|
199
|
+
.map((i) => link(i.number))
|
|
200
|
+
.join(t('join.items'))
|
|
201
|
+
parts.push(t('lede.ready', { n: ready.length, issues: first }))
|
|
202
|
+
} else {
|
|
203
|
+
parts.push(t('lede.none'))
|
|
204
|
+
}
|
|
205
|
+
if (counts.blocked) parts.push(t('lede.blocked', { n: counts.blocked }))
|
|
206
|
+
if (counts.triage) parts.push(t('lede.triage', { n: counts.triage }))
|
|
207
|
+
if (criticalPath > 1) parts.push(t('lede.critical', { n: criticalPath }))
|
|
208
|
+
return parts.join(' ')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const statsHTML = (): string => {
|
|
212
|
+
const tiles: { label: string; value: number; unit?: string; tone?: string }[] = [
|
|
213
|
+
{ label: t('stat.ready'), value: counts.ready, tone: 'ready' },
|
|
214
|
+
{ label: t('stat.active'), value: counts.active, tone: 'active' },
|
|
215
|
+
{ label: t('stat.blocked'), value: counts.blocked, unit: `/ ${openCount}`, tone: 'blocked' },
|
|
216
|
+
{ label: t('stat.triage'), value: counts.triage, tone: 'triage' },
|
|
217
|
+
{
|
|
218
|
+
label: t('stat.critical'),
|
|
219
|
+
value: criticalPath,
|
|
220
|
+
unit: t('stat.critical.unit', { n: criticalPath }),
|
|
221
|
+
},
|
|
222
|
+
]
|
|
223
|
+
return tiles
|
|
224
|
+
.map((tile) => {
|
|
225
|
+
const tone = tile.tone ? ` data-tone="${tile.tone}"` : ''
|
|
226
|
+
const unit = tile.unit ? `<small>${esc(tile.unit)}</small>` : ''
|
|
227
|
+
return `<div class="stat"${tone}><b>${tile.value}${unit}</b><span>${esc(tile.label)}</span></div>`
|
|
228
|
+
})
|
|
229
|
+
.join('')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 頁尾的三段。**三段都是 HTML**,不是純文字——`refresh` 帶 `<code>`,另外兩段是逃脫過的文字,
|
|
234
|
+
* 所以兩邊都要當 HTML 塞。當成文字塞的話逃脫會被看見:repo 把標籤取名 `A&B` 時畫面上會出現
|
|
235
|
+
* `A&B`。
|
|
236
|
+
*/
|
|
237
|
+
const footerHTML = (): { truth: string; refresh: string; config: string } => {
|
|
238
|
+
const code = (command: string) => `<code>${esc(command)}</code>`
|
|
239
|
+
const vocab = snapshot.labels
|
|
240
|
+
return {
|
|
241
|
+
truth: esc(t('foot.truth')),
|
|
242
|
+
refresh: t('foot.refresh', {
|
|
243
|
+
build: code('bun run issue-map'),
|
|
244
|
+
serve: code('bun run issue-map:serve'),
|
|
245
|
+
}),
|
|
246
|
+
// 標籤名是 repo 給的字,逃脫過才進 innerHTML。
|
|
247
|
+
config: esc(
|
|
248
|
+
// 照快照記下的閘門說話。字彙一律有預設值、永遠非空,拿它的長度判斷的話這句必然說謊。
|
|
249
|
+
vocab.gated
|
|
250
|
+
? t('foot.vocab', {
|
|
251
|
+
ready: vocab.ready.join(t('join.or')),
|
|
252
|
+
unready: vocab.unready.join(t('join.slash')),
|
|
253
|
+
})
|
|
254
|
+
: t('foot.noVocab'),
|
|
255
|
+
),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- 線路圖 ----
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 同一條線就是一橫。跨線走「向右、轉、向下、轉、向右」,而垂直那一段刻意走在站與站之間的
|
|
263
|
+
* 間隙裡——走中點的話會壓到中間那幾條線的站名。同一個終點有多條邊時各自錯開一點,不然它們
|
|
264
|
+
* 會完全重疊成一條。
|
|
265
|
+
*/
|
|
266
|
+
const railPath = (a: Point, b: Point, nudge: number): string => {
|
|
267
|
+
const x1 = a.x + MAP.dot + 3
|
|
268
|
+
const x2 = b.x - MAP.dot - 5
|
|
269
|
+
if (a.y === b.y) return `M${x1} ${a.y} H${x2}`
|
|
270
|
+
const gap = Math.max(x1 + MAP.bend + 2, b.x - MAP.step * 0.44 + nudge * 7)
|
|
271
|
+
const turn = Math.min(gap, x2 - MAP.bend - 2)
|
|
272
|
+
const dir = b.y > a.y ? 1 : -1
|
|
273
|
+
return (
|
|
274
|
+
`M${x1} ${a.y} H${turn - MAP.bend}` +
|
|
275
|
+
` Q${turn} ${a.y} ${turn} ${a.y + dir * MAP.bend}` +
|
|
276
|
+
` V${b.y - dir * MAP.bend}` +
|
|
277
|
+
` Q${turn} ${b.y} ${turn + MAP.bend} ${b.y}` +
|
|
278
|
+
` H${x2}`
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const svgTag = (tag: string, attrs: Readonly<Record<string, string | number>>): string => {
|
|
283
|
+
const pairs = Object.entries(attrs)
|
|
284
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
285
|
+
.join(' ')
|
|
286
|
+
return `<${tag} ${pairs}/>`
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 站點用形狀分辨,不只靠線條粗細——粗細在 7px 的圓點上分不出來。
|
|
291
|
+
*
|
|
292
|
+
* 圓是流程上的票(實心已關、空心等前置、靶心加光暈是現在可動),三角是有人在做,菱形是規格
|
|
293
|
+
* 還沒定案。兩個例外各給一個形狀,掃一眼就分得開。
|
|
294
|
+
*/
|
|
295
|
+
const stationShape = (status: Status, q: Point): string => {
|
|
296
|
+
if (status === 'active') {
|
|
297
|
+
// 往右指的三角形:這一站正在往下一站走。
|
|
298
|
+
const r = MAP.dot + 1.5
|
|
299
|
+
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`
|
|
300
|
+
return svgTag('path', { class: 'mark', d })
|
|
301
|
+
}
|
|
302
|
+
if (status === 'triage') {
|
|
303
|
+
const d = MAP.dot + 1
|
|
304
|
+
return svgTag('rect', {
|
|
305
|
+
class: 'mark',
|
|
306
|
+
x: q.x - d,
|
|
307
|
+
y: q.y - d,
|
|
308
|
+
width: d * 2,
|
|
309
|
+
height: d * 2,
|
|
310
|
+
rx: 1,
|
|
311
|
+
transform: `rotate(45 ${q.x} ${q.y})`,
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
const dot = svgTag('circle', {
|
|
315
|
+
class: 'mark',
|
|
316
|
+
cx: q.x,
|
|
317
|
+
cy: q.y,
|
|
318
|
+
r: status === 'ready' ? MAP.dot + 2 : MAP.dot,
|
|
319
|
+
})
|
|
320
|
+
if (status !== 'ready') return dot
|
|
321
|
+
return (
|
|
322
|
+
svgTag('circle', { class: 'halo', cx: q.x, cy: q.y, r: MAP.dot + 7 }) +
|
|
323
|
+
dot +
|
|
324
|
+
svgTag('circle', { class: 'core', cx: q.x, cy: q.y, r: 3 })
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const railHTML = (edge: Edge, layout: Layout, nudge: number): string => {
|
|
329
|
+
const a = layout.xy.get(edge.from)
|
|
330
|
+
const b = layout.xy.get(edge.to)
|
|
331
|
+
if (!a || !b) return ''
|
|
332
|
+
const done = issueAt(edge.from)?.status === 'done'
|
|
333
|
+
return svgTag('path', {
|
|
334
|
+
class: 'edge',
|
|
335
|
+
d: railPath(a, b, nudge),
|
|
336
|
+
'data-from': edge.from,
|
|
337
|
+
'data-to': edge.to,
|
|
338
|
+
'data-done': String(done),
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const stationHTML = (issue: MapIssue, q: Point): string => {
|
|
343
|
+
const aria = esc(
|
|
344
|
+
t('node.aria', { n: issue.number, title: issue.title, status: statusLabel(issue.status) }),
|
|
345
|
+
)
|
|
346
|
+
return (
|
|
347
|
+
`<a class="node" href="${esc(issue.url)}" target="_blank" rel="noopener"` +
|
|
348
|
+
` aria-label="${aria}" data-status="${issue.status}" data-number="${issue.number}">` +
|
|
349
|
+
stationShape(issue.status, q) +
|
|
350
|
+
`<text class="sid" x="${q.x}" y="${q.y - 15}" text-anchor="middle">#${issue.number}</text>` +
|
|
351
|
+
`<text class="sdesc" x="${q.x}" y="${q.y + 24}" text-anchor="middle">${esc(head(issue.title))}</text>` +
|
|
352
|
+
`<title>#${issue.number} ${esc(issue.title)}</title>` +
|
|
353
|
+
'</a>'
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 線名畫在 SVG 外面的一個 HTML 欄位裡,不是 SVG 的 `<text>`。
|
|
359
|
+
*
|
|
360
|
+
* SVG 的文字不會換行也不會自己截斷,長一點的線名(「互不阻擋,可各自開工」那種,而且每種語言
|
|
361
|
+
* 長度不同)會直接蓋到第一個站點上。放進 HTML 之後 `text-overflow` 就管得到,而且不需要任何
|
|
362
|
+
* JS 量寬度——沒有 JS 的環境也不會疊在一起。
|
|
363
|
+
*/
|
|
364
|
+
const trackLabelsHTML = (layout: Layout): string => {
|
|
365
|
+
const labels: string[] = []
|
|
366
|
+
layout.tracks.forEach((chain, index) => {
|
|
367
|
+
const terminus = chain[chain.length - 1]
|
|
368
|
+
// 單站線沒有「這條線在做什麼」可講,不標。
|
|
369
|
+
if (chain.length < 2 || terminus === undefined) return
|
|
370
|
+
labels.push(
|
|
371
|
+
`<div class="tlabel" style="top:${MAP.top + index * MAP.row}px">` +
|
|
372
|
+
`<b>→ #${terminus}</b><span>${esc(t('map.stations', { n: chain.length }))}</span></div>`,
|
|
373
|
+
)
|
|
374
|
+
})
|
|
375
|
+
if (layout.islandRows) {
|
|
376
|
+
labels.push(
|
|
377
|
+
`<div class="tlabel" style="top:${MAP.top + layout.tracks.length * MAP.row}px">` +
|
|
378
|
+
`<b>${esc(t('map.islandName'))}</b><span>${esc(t('map.islandSub'))}</span></div>`,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
return labels.join('')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `parent` 不是 null 時,圖本體跟著那張主票收合——收合的對象由呼叫端指定。 */
|
|
385
|
+
const mapHTML = (shown: Shown, parent: number | null): string => {
|
|
386
|
+
const layout = layoutOf(shown.members)
|
|
387
|
+
const seenTo = new Map<number, number>()
|
|
388
|
+
const rails = layout.edges
|
|
389
|
+
.map((edge) => {
|
|
390
|
+
const nth = seenTo.get(edge.to) ?? 0
|
|
391
|
+
seenTo.set(edge.to, nth + 1)
|
|
392
|
+
return railHTML(edge, layout, nth)
|
|
393
|
+
})
|
|
394
|
+
.join('')
|
|
395
|
+
const stations = shown.members
|
|
396
|
+
.map((member) => {
|
|
397
|
+
const q = layout.xy.get(member.number)
|
|
398
|
+
return q ? stationHTML(member, q) : ''
|
|
399
|
+
})
|
|
400
|
+
.join('')
|
|
401
|
+
return (
|
|
402
|
+
`<div class="map-wrap"${foldTarget(parent)} style="--track:${shown.track}">` +
|
|
403
|
+
trackLabelsHTML(layout) +
|
|
404
|
+
`<svg width="${layout.width}" height="${layout.height}"` +
|
|
405
|
+
` viewBox="0 0 ${layout.width} ${layout.height}" role="img">` +
|
|
406
|
+
rails +
|
|
407
|
+
stations +
|
|
408
|
+
'</svg></div>'
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 每張圖底下重複一份 key。圖可以收起來,key 跟著收,不會留一段沒有圖的說明。
|
|
414
|
+
*
|
|
415
|
+
* 五個項目照 `STATUS_ORDER` 長出來,順序與狀態本身同一份;class 是 `k-<狀態>`,文案鍵是
|
|
416
|
+
* `legend.<狀態>`,所以新增一個狀態不必回來改這裡。
|
|
417
|
+
*/
|
|
418
|
+
const mapKeyHTML = (): string =>
|
|
419
|
+
'<div class="map-key">' +
|
|
420
|
+
STATUS_ORDER.map(
|
|
421
|
+
(status) => `<span><i class="k-${status}"></i>${esc(t(`legend.${status}`))}</span>`,
|
|
422
|
+
).join('') +
|
|
423
|
+
`<span>${esc(t('legend.solid'))}</span>` +
|
|
424
|
+
`<span>${esc(t('legend.dashed'))}</span>` +
|
|
425
|
+
'</div>'
|
|
426
|
+
|
|
427
|
+
/** 一群票的標題。主票那一群用主票標題(真資料),其他三種是頁面自己的分類。 */
|
|
428
|
+
const groupTitle = (group: Group): string => {
|
|
429
|
+
switch (group.name.kind) {
|
|
430
|
+
case 'spec':
|
|
431
|
+
return group.name.title
|
|
432
|
+
case 'orphan':
|
|
433
|
+
return t('group.orphan', { n: group.name.parent })
|
|
434
|
+
case 'linked':
|
|
435
|
+
return t('group.linked')
|
|
436
|
+
case 'island':
|
|
437
|
+
return t('group.island')
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const groupSub = (shown: Shown): string => {
|
|
442
|
+
const parent = shown.group.parent
|
|
443
|
+
// 「其他依賴鏈」與「獨立票」都沒有主票,但只有後者互不阻擋——同一句話蓋兩種群會說謊。
|
|
444
|
+
if (shown.group.name.kind === 'linked') return esc(t('group.linkedSub'))
|
|
445
|
+
if (parent === null) return esc(t('group.islandSub'))
|
|
446
|
+
const done = shown.members.filter((m) => m.status === 'done').length
|
|
447
|
+
const spec = issueAt(parent)
|
|
448
|
+
const specLink = spec
|
|
449
|
+
? `<a href="${esc(spec.url)}" target="_blank" rel="noopener">${esc(
|
|
450
|
+
t('group.spec', { n: parent }),
|
|
451
|
+
)}</a> · `
|
|
452
|
+
: ''
|
|
453
|
+
return specLink + esc(t('group.progress', { done, total: shown.members.length }))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const foldButtonHTML = (parent: number): string =>
|
|
457
|
+
`<button type="button" class="fold" data-fold-for="${parent}" aria-expanded="true"></button>`
|
|
458
|
+
|
|
459
|
+
/** 掛了這個屬性的東西會跟著那張主票一起收起來。沒有主票的群收不了,就不掛。 */
|
|
460
|
+
const foldTarget = (parent: number | null): string =>
|
|
461
|
+
parent === null ? '' : ` data-parent="${parent}"`
|
|
462
|
+
|
|
463
|
+
const shownGroups = (): Shown[] =>
|
|
464
|
+
snapshot.groups.map((group, index) => ({
|
|
465
|
+
group,
|
|
466
|
+
members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
|
|
467
|
+
track: `var(${TRACK_COLOURS[index % TRACK_COLOURS.length]})`,
|
|
468
|
+
}))
|
|
469
|
+
|
|
470
|
+
/** 這一組裡有沒有票互相擋著。沒有的話畫出來只是一片點陣,不是線路圖。 */
|
|
471
|
+
const hasRails = (shown: Shown): boolean => edgesWithin(shown.members).length > 0
|
|
472
|
+
|
|
473
|
+
const groupsHTML = (): string => {
|
|
474
|
+
const groups = shownGroups()
|
|
475
|
+
// 有線路的一定畫;其餘照原順序補到上限。剩下的只留標頭,票照樣在下面的清單裡。
|
|
476
|
+
const drawn = new Set(groups.filter(hasRails))
|
|
477
|
+
for (const shown of groups) {
|
|
478
|
+
if (drawn.size >= MAX_MAPS) break
|
|
479
|
+
drawn.add(shown)
|
|
480
|
+
}
|
|
481
|
+
return groups
|
|
482
|
+
.map((shown) => {
|
|
483
|
+
const parent = shown.group.parent
|
|
484
|
+
const fold = parent === null ? '' : foldButtonHTML(parent)
|
|
485
|
+
const attrs = parent === null ? '' : ` data-fold="${parent}"`
|
|
486
|
+
const body = drawn.has(shown)
|
|
487
|
+
? mapHTML(shown, parent) + mapKeyHTML()
|
|
488
|
+
: `<p class="undrawn"${foldTarget(parent)}>${esc(t('group.undrawn'))}</p>`
|
|
489
|
+
return (
|
|
490
|
+
`<section class="group" style="--track:${shown.track}"${attrs}>` +
|
|
491
|
+
`<div class="group-head">${fold}<h2>${esc(groupTitle(shown.group))}</h2>` +
|
|
492
|
+
`<span class="sub">${groupSub(shown)}</span></div>` +
|
|
493
|
+
body +
|
|
494
|
+
'</section>'
|
|
495
|
+
)
|
|
496
|
+
})
|
|
497
|
+
.join('')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---- 詳細 ----
|
|
501
|
+
|
|
502
|
+
/** 一張票的細節。上面的面板與清單裡展開的那一列共用同一份標記。 */
|
|
503
|
+
const detailHTML = (number: number): string => {
|
|
504
|
+
const issue = issueAt(number)
|
|
505
|
+
if (!issue) return ''
|
|
506
|
+
const settled = issue.blockedBy.filter((b) => !issue.waitingFor.includes(b))
|
|
507
|
+
const opens = (unlocks.get(number) ?? []).filter((n) => issueAt(n)?.status !== 'done')
|
|
508
|
+
const closed = issue.closedAt
|
|
509
|
+
? esc(t('detail.closedAt', { date: dateOnly(issue.closedAt) }))
|
|
510
|
+
: ''
|
|
511
|
+
const rows: [string, string][] = [
|
|
512
|
+
[t('detail.status'), pill(issue) + closed],
|
|
513
|
+
[t('detail.next'), stepCell(issue)],
|
|
514
|
+
]
|
|
515
|
+
if (issue.author) rows.push([t('detail.author'), esc(issue.author)])
|
|
516
|
+
if (issue.parent !== null) rows.push([t('detail.parent'), link(issue.parent)])
|
|
517
|
+
if (issue.waitingFor.length) {
|
|
518
|
+
rows.push([t('detail.waiting'), issue.waitingFor.map(link).join(' ')])
|
|
519
|
+
}
|
|
520
|
+
if (settled.length) rows.push([t('detail.settled'), settled.map(link).join(' ')])
|
|
521
|
+
if (opens.length) rows.push([t('detail.unlocks'), opens.map(link).join(' ')])
|
|
522
|
+
if (issue.labels.length) {
|
|
523
|
+
rows.push([
|
|
524
|
+
t('detail.labels'),
|
|
525
|
+
issue.labels.map((l) => `<span class="label">${esc(l)}</span>`).join(''),
|
|
526
|
+
])
|
|
527
|
+
}
|
|
528
|
+
if (issue.assignees.length) {
|
|
529
|
+
rows.push([t('detail.assignees'), esc(issue.assignees.join(', '))])
|
|
530
|
+
}
|
|
531
|
+
return (
|
|
532
|
+
`<h3><span class="num">#${issue.number}</span>${esc(issue.title)}</h3>` +
|
|
533
|
+
`<a class="open" href="${esc(issue.url)}" target="_blank" rel="noopener">${esc(
|
|
534
|
+
t('detail.open'),
|
|
535
|
+
)}</a>` +
|
|
536
|
+
`<dl class="rows">${rows.map(([dt, dd]) => `<dt>${esc(dt)}</dt><dd>${dd}</dd>`).join('')}</dl>`
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** 沒有選取時預設看哪一張:第一張可接手的,再不然就第一張。 */
|
|
541
|
+
const defaultPick = (): number | undefined => (frontline()[0] ?? issues[0])?.number
|
|
542
|
+
|
|
543
|
+
const detailPanelHTML = (number: number | undefined): { status: string; html: string } => {
|
|
544
|
+
const issue = number === undefined ? undefined : issueAt(number)
|
|
545
|
+
if (!issue) return { status: '', html: `<h3>${esc(t('detail.empty'))}</h3>` }
|
|
546
|
+
return { status: issue.status, html: detailHTML(issue.number) }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---- 清單 ----
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 主票在前、它的子票跟在後面。
|
|
553
|
+
*
|
|
554
|
+
* `parent` 是一個指標欄位,語意上就是任意深度的森林,所以這裡真的把它折成森林再深度優先
|
|
555
|
+
* 展平——**每張票只會出現一次**。把它當成「有沒有 parent 就是 root」的兩層結構、再補一條
|
|
556
|
+
* 「把漏掉的主票撿回來」的例外的話,三層鏈的中間那張會同時是子票又是主票,渲染成兩列。
|
|
557
|
+
*
|
|
558
|
+
* 篩選時祖先只要有後代入選就留著當標頭,否則子票沒了歸屬。
|
|
559
|
+
*/
|
|
560
|
+
const rowOrder = (filter: Filter): Row[] => {
|
|
561
|
+
const kids = new Map<number, MapIssue[]>()
|
|
562
|
+
const roots: MapIssue[] = []
|
|
563
|
+
for (const issue of issues) {
|
|
564
|
+
// 指向沒被帶進快照的 parent,就當它自己是一枝的頂端。
|
|
565
|
+
const parent = issue.parent !== null && byNumber.has(issue.parent) ? issue.parent : null
|
|
566
|
+
if (parent === null) {
|
|
567
|
+
roots.push(issue)
|
|
568
|
+
continue
|
|
569
|
+
}
|
|
570
|
+
const siblings = kids.get(parent) ?? []
|
|
571
|
+
siblings.push(issue)
|
|
572
|
+
kids.set(parent, siblings)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const hits = (issue: MapIssue): boolean => filter === 'all' || issue.status === filter
|
|
576
|
+
const rows: Row[] = []
|
|
577
|
+
const seen = new Set<number>()
|
|
578
|
+
|
|
579
|
+
/** 展平這一枝,回傳它有沒有東西入選。整枝都沒入選就把自己也收回去。 */
|
|
580
|
+
const walk = (issue: MapIssue, parent: number | null, depth: number): boolean => {
|
|
581
|
+
// parent 成環時止血:資料不該有環,真的有就讓它在這裡停下來而不是無限遞迴。
|
|
582
|
+
if (seen.has(issue.number)) return false
|
|
583
|
+
seen.add(issue.number)
|
|
584
|
+
const at = rows.length
|
|
585
|
+
rows.push({ issue, parent, depth, hasKids: false })
|
|
586
|
+
let keptChild = false
|
|
587
|
+
for (const child of (kids.get(issue.number) ?? []).sort(byStatusThenNumber)) {
|
|
588
|
+
keptChild = walk(child, issue.number, depth + 1) || keptChild
|
|
589
|
+
}
|
|
590
|
+
if (!keptChild && !hits(issue)) {
|
|
591
|
+
// 整枝沒人入選,連自己一起收回去——沒有後代被留下,這裡只會砍到自己那一列。
|
|
592
|
+
rows.length = at
|
|
593
|
+
return false
|
|
594
|
+
}
|
|
595
|
+
rows[at] = { issue, parent, depth, hasKids: keptChild }
|
|
596
|
+
return true
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
for (const root of [...roots].sort(byStatusThenNumber)) walk(root, null, 0)
|
|
600
|
+
// 成環的票進不了任何一枝,但它們還是得看得見。
|
|
601
|
+
const stranded = issues.filter((issue) => !seen.has(issue.number)).sort(byStatusThenNumber)
|
|
602
|
+
for (const issue of stranded) walk(issue, null, 0)
|
|
603
|
+
return rows
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const rowHTML = (row: Row): string => {
|
|
607
|
+
const issue = row.issue
|
|
608
|
+
// 縮排的級距在 CSS 裡,這裡只說第幾層。
|
|
609
|
+
const parent =
|
|
610
|
+
row.parent === null ? '' : ` data-parent="${row.parent}" style="--depth:${row.depth}"`
|
|
611
|
+
const kids = row.hasKids ? ' data-haskids="true"' : ''
|
|
612
|
+
const slot = row.hasKids ? `<span class="fold-slot">${foldButtonHTML(issue.number)}</span>` : ''
|
|
613
|
+
const waitsTitle =
|
|
614
|
+
issue.waitingFor.length > BLOCKERS_SHOWN
|
|
615
|
+
? ` title="${esc(issue.waitingFor.map(hash).join(' '))}"`
|
|
616
|
+
: ''
|
|
617
|
+
const waits = issue.waitingFor.length
|
|
618
|
+
? `<span class="waits"${waitsTitle}>${t('row.waits', {
|
|
619
|
+
list: blockers(issue.waitingFor),
|
|
620
|
+
})}</span>`
|
|
621
|
+
: ''
|
|
622
|
+
return (
|
|
623
|
+
`<div class="row" tabindex="0" data-status="${issue.status}" data-number="${issue.number}"${parent}${kids}>` +
|
|
624
|
+
`<div class="id">${slot}` +
|
|
625
|
+
`<a class="no" href="${esc(issue.url)}" target="_blank" rel="noopener">#${issue.number}</a>` +
|
|
626
|
+
`<span class="who">${issue.author ? esc(issue.author) : '—'}</span></div>` +
|
|
627
|
+
`<div class="what"><span class="title">${esc(issue.title)}</span>${waits}</div>` +
|
|
628
|
+
`<div class="step">${stepCell(issue)}</div>` +
|
|
629
|
+
`<div class="gate">${pill(issue)}</div>` +
|
|
630
|
+
'</div>'
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const rowsHTML = (filter: Filter): { html: string; shown: number } => {
|
|
635
|
+
const rows = rowOrder(filter)
|
|
636
|
+
return {
|
|
637
|
+
html: rows.map(rowHTML).join(''),
|
|
638
|
+
shown: rows.filter((r) => !r.issue.isParent).length,
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const tabsHTML = (filter: Filter): string => {
|
|
643
|
+
const defs: [Filter, string, number][] = [
|
|
644
|
+
['all', t('tabs.all'), work.length],
|
|
645
|
+
...STATUS_ORDER.map((s) => [s, statusLabel(s), counts[s]] as [Status, string, number]),
|
|
646
|
+
]
|
|
647
|
+
return defs
|
|
648
|
+
.map(
|
|
649
|
+
([key, label, count]) =>
|
|
650
|
+
`<button type="button" class="tab" data-filter="${key}"` +
|
|
651
|
+
` aria-pressed="${key === filter}">${esc(label)}<span class="n">${count}</span></button>`,
|
|
652
|
+
)
|
|
653
|
+
.join('')
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** 一張票的整條上下游,用來在選取時把鏈亮起來。 */
|
|
657
|
+
const chainOf = (start: number): Set<number> => {
|
|
658
|
+
const out = new Set([start])
|
|
659
|
+
const walks: ((n: number) => readonly number[])[] = [
|
|
660
|
+
(n) => issueAt(n)?.blockedBy ?? [],
|
|
661
|
+
(n) => unlocks.get(n) ?? [],
|
|
662
|
+
]
|
|
663
|
+
for (const neighboursOf of walks) {
|
|
664
|
+
const stack = [start]
|
|
665
|
+
while (stack.length) {
|
|
666
|
+
const at = stack.pop()
|
|
667
|
+
if (at === undefined) break
|
|
668
|
+
for (const next of neighboursOf(at)) {
|
|
669
|
+
if (out.has(next)) continue
|
|
670
|
+
out.add(next)
|
|
671
|
+
stack.push(next)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return out
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
issues,
|
|
680
|
+
issueAt,
|
|
681
|
+
chainOf,
|
|
682
|
+
defaultPick,
|
|
683
|
+
title,
|
|
684
|
+
eyebrow,
|
|
685
|
+
lede,
|
|
686
|
+
statsHTML,
|
|
687
|
+
footerHTML,
|
|
688
|
+
groupsHTML,
|
|
689
|
+
detailHTML,
|
|
690
|
+
detailPanelHTML,
|
|
691
|
+
rowsHTML,
|
|
692
|
+
tabsHTML,
|
|
693
|
+
}
|
|
694
|
+
}
|