issue-map 0.4.0 → 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 +16 -13
- package/README.md +16 -13
- package/README.zh-CN.md +16 -13
- package/README.zh-TW.md +16 -13
- package/package.json +7 -13
- package/{scripts → src}/issue-map-model.ts +44 -22
- package/{scripts → src}/issue-map-page.ts +62 -38
- package/{scripts → src}/issue-map-serve.ts +17 -2
- package/{scripts → src}/issue-map-view.ts +108 -64
- package/{scripts → src}/issue-map.html +2 -1
- package/{scripts → src}/issue-map.ts +275 -129
- /package/{scripts → src}/issue-map-i18n.ts +0 -0
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* 文案一律走 `issue-map-i18n.ts` 的 `t()`,它讀的是當下語言。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { locale, t } from './issue-map-i18n.ts'
|
|
11
|
+
import { LOCALE_NAME, LOCALES, locale, t } from './issue-map-i18n.ts'
|
|
12
12
|
import {
|
|
13
13
|
type Edge,
|
|
14
|
+
edgesWithin,
|
|
14
15
|
type Group,
|
|
15
16
|
layoutOf,
|
|
16
17
|
type Layout,
|
|
@@ -34,6 +35,14 @@ export function esc(text: string): string {
|
|
|
34
35
|
return text.replace(/[&<>"]/g, (c) => ESCAPES[c] ?? c)
|
|
35
36
|
}
|
|
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
|
+
|
|
37
46
|
/**
|
|
38
47
|
* 站名就是標題開頭。刻意不另設一個「短名」欄位——那要每張票靠人維護,而且會變成票名的第二個
|
|
39
48
|
* 來源,改了標題不會跟著改。截到讀不通的時候,滑鼠停留與下面的清單都有完整標題。
|
|
@@ -57,7 +66,13 @@ const MAX_MAPS = 20
|
|
|
57
66
|
/** 一群票對應的一張圖。`members` 是票,`track` 是這一組的線色。 */
|
|
58
67
|
export type Shown = { group: Group; members: readonly MapIssue[]; track: string }
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
/** 清單上的一列。`parent` 是直屬主票,`depth` 是它在 parent 樹裡的層數(頂層是 0)。 */
|
|
70
|
+
export type Row = {
|
|
71
|
+
issue: MapIssue
|
|
72
|
+
parent: number | null
|
|
73
|
+
depth: number
|
|
74
|
+
hasKids: boolean
|
|
75
|
+
}
|
|
61
76
|
|
|
62
77
|
export type Filter = Status | 'all'
|
|
63
78
|
|
|
@@ -67,12 +82,12 @@ export type Filter = Status | 'all'
|
|
|
67
82
|
* 先算一次衍生資料(計數、反向索引),再把畫的函式掛上去——兩側都只要 `viewOf(snapshot)` 一次
|
|
68
83
|
* 就能重複畫。
|
|
69
84
|
*/
|
|
70
|
-
export function viewOf(snapshot:
|
|
71
|
-
const issues: readonly MapIssue[] = snapshot.issues
|
|
85
|
+
export function viewOf(snapshot: Snapshot) {
|
|
86
|
+
const issues: readonly MapIssue[] = snapshot.issues
|
|
72
87
|
const byNumber = new Map(issues.map((issue) => [issue.number, issue]))
|
|
73
88
|
const work = issues.filter((issue) => !issue.isParent)
|
|
74
|
-
const criticalPath = snapshot.criticalPath
|
|
75
|
-
const repo = snapshot.repo
|
|
89
|
+
const criticalPath = snapshot.criticalPath
|
|
90
|
+
const repo = snapshot.repo
|
|
76
91
|
|
|
77
92
|
const counts = Object.fromEntries(
|
|
78
93
|
STATUS_ORDER.map((status) => [status, work.filter((i) => i.status === status).length]),
|
|
@@ -96,6 +111,13 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
96
111
|
|
|
97
112
|
const statusLabel = (status: Status): string => t(`status.${status}`)
|
|
98
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 現在可動的票,照清單的順序。導言與詳細面板的預設那一張都讀它——兩邊各算一次的話,導言說
|
|
116
|
+
* 「最前面是 #N」而面板開的卻是別張。
|
|
117
|
+
*/
|
|
118
|
+
const frontline = (): MapIssue[] =>
|
|
119
|
+
work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
|
|
120
|
+
|
|
99
121
|
/** 日期一律照當下語言排。快照裡存的是 ISO 字串,格式化是畫面的事。 */
|
|
100
122
|
const dateTime = (iso: string): string =>
|
|
101
123
|
new Date(iso).toLocaleString(locale(), { hour12: false })
|
|
@@ -169,14 +191,14 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
169
191
|
/** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
|
|
170
192
|
const lede = (): string => {
|
|
171
193
|
if (!openCount) return t('lede.allDone')
|
|
172
|
-
const
|
|
194
|
+
const ready = frontline()
|
|
173
195
|
const parts = [t('lede.open', { n: openCount })]
|
|
174
|
-
if (
|
|
175
|
-
const first =
|
|
196
|
+
if (ready.length) {
|
|
197
|
+
const first = ready
|
|
176
198
|
.slice(0, 3)
|
|
177
|
-
.map((i) =>
|
|
199
|
+
.map((i) => link(i.number))
|
|
178
200
|
.join(t('join.items'))
|
|
179
|
-
parts.push(t('lede.ready', { n:
|
|
201
|
+
parts.push(t('lede.ready', { n: ready.length, issues: first }))
|
|
180
202
|
} else {
|
|
181
203
|
parts.push(t('lede.none'))
|
|
182
204
|
}
|
|
@@ -207,9 +229,14 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
207
229
|
.join('')
|
|
208
230
|
}
|
|
209
231
|
|
|
232
|
+
/**
|
|
233
|
+
* 頁尾的三段。**三段都是 HTML**,不是純文字——`refresh` 帶 `<code>`,另外兩段是逃脫過的文字,
|
|
234
|
+
* 所以兩邊都要當 HTML 塞。當成文字塞的話逃脫會被看見:repo 把標籤取名 `A&B` 時畫面上會出現
|
|
235
|
+
* `A&B`。
|
|
236
|
+
*/
|
|
210
237
|
const footerHTML = (): { truth: string; refresh: string; config: string } => {
|
|
211
238
|
const code = (command: string) => `<code>${esc(command)}</code>`
|
|
212
|
-
const vocab = snapshot.labels
|
|
239
|
+
const vocab = snapshot.labels
|
|
213
240
|
return {
|
|
214
241
|
truth: esc(t('foot.truth')),
|
|
215
242
|
refresh: t('foot.refresh', {
|
|
@@ -218,7 +245,8 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
218
245
|
}),
|
|
219
246
|
// 標籤名是 repo 給的字,逃脫過才進 innerHTML。
|
|
220
247
|
config: esc(
|
|
221
|
-
|
|
248
|
+
// 照快照記下的閘門說話。字彙一律有預設值、永遠非空,拿它的長度判斷的話這句必然說謊。
|
|
249
|
+
vocab.gated
|
|
222
250
|
? t('foot.vocab', {
|
|
223
251
|
ready: vocab.ready.join(t('join.or')),
|
|
224
252
|
unready: vocab.unready.join(t('join.slash')),
|
|
@@ -346,14 +374,15 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
346
374
|
})
|
|
347
375
|
if (layout.islandRows) {
|
|
348
376
|
labels.push(
|
|
349
|
-
`<div class="tlabel" style="top:${MAP.top + layout.
|
|
377
|
+
`<div class="tlabel" style="top:${MAP.top + layout.tracks.length * MAP.row}px">` +
|
|
350
378
|
`<b>${esc(t('map.islandName'))}</b><span>${esc(t('map.islandSub'))}</span></div>`,
|
|
351
379
|
)
|
|
352
380
|
}
|
|
353
381
|
return labels.join('')
|
|
354
382
|
}
|
|
355
383
|
|
|
356
|
-
|
|
384
|
+
/** `parent` 不是 null 時,圖本體跟著那張主票收合——收合的對象由呼叫端指定。 */
|
|
385
|
+
const mapHTML = (shown: Shown, parent: number | null): string => {
|
|
357
386
|
const layout = layoutOf(shown.members)
|
|
358
387
|
const seenTo = new Map<number, number>()
|
|
359
388
|
const rails = layout.edges
|
|
@@ -370,7 +399,7 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
370
399
|
})
|
|
371
400
|
.join('')
|
|
372
401
|
return (
|
|
373
|
-
`<div class="map-wrap" style="--track:${shown.track}">` +
|
|
402
|
+
`<div class="map-wrap"${foldTarget(parent)} style="--track:${shown.track}">` +
|
|
374
403
|
trackLabelsHTML(layout) +
|
|
375
404
|
`<svg width="${layout.width}" height="${layout.height}"` +
|
|
376
405
|
` viewBox="0 0 ${layout.width} ${layout.height}" role="img">` +
|
|
@@ -380,23 +409,20 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
380
409
|
)
|
|
381
410
|
}
|
|
382
411
|
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
'</div>'
|
|
398
|
-
)
|
|
399
|
-
}
|
|
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>'
|
|
400
426
|
|
|
401
427
|
/** 一群票的標題。主票那一群用主票標題(真資料),其他三種是頁面自己的分類。 */
|
|
402
428
|
const groupTitle = (group: Group): string => {
|
|
@@ -430,18 +456,19 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
430
456
|
const foldButtonHTML = (parent: number): string =>
|
|
431
457
|
`<button type="button" class="fold" data-fold-for="${parent}" aria-expanded="true"></button>`
|
|
432
458
|
|
|
459
|
+
/** 掛了這個屬性的東西會跟著那張主票一起收起來。沒有主票的群收不了,就不掛。 */
|
|
460
|
+
const foldTarget = (parent: number | null): string =>
|
|
461
|
+
parent === null ? '' : ` data-parent="${parent}"`
|
|
462
|
+
|
|
433
463
|
const shownGroups = (): Shown[] =>
|
|
434
|
-
|
|
464
|
+
snapshot.groups.map((group, index) => ({
|
|
435
465
|
group,
|
|
436
466
|
members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
|
|
437
467
|
track: `var(${TRACK_COLOURS[index % TRACK_COLOURS.length]})`,
|
|
438
468
|
}))
|
|
439
469
|
|
|
440
470
|
/** 這一組裡有沒有票互相擋著。沒有的話畫出來只是一片點陣,不是線路圖。 */
|
|
441
|
-
const hasRails = (shown: Shown): boolean =>
|
|
442
|
-
const inGroup = new Set(shown.members.map((m) => m.number))
|
|
443
|
-
return shown.members.some((m) => m.blockedBy.some((n) => inGroup.has(n)))
|
|
444
|
-
}
|
|
471
|
+
const hasRails = (shown: Shown): boolean => edgesWithin(shown.members).length > 0
|
|
445
472
|
|
|
446
473
|
const groupsHTML = (): string => {
|
|
447
474
|
const groups = shownGroups()
|
|
@@ -456,11 +483,9 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
456
483
|
const parent = shown.group.parent
|
|
457
484
|
const fold = parent === null ? '' : foldButtonHTML(parent)
|
|
458
485
|
const attrs = parent === null ? '' : ` data-fold="${parent}"`
|
|
459
|
-
const bodyAttrs = parent === null ? '' : ` data-parent="${parent}"`
|
|
460
486
|
const body = drawn.has(shown)
|
|
461
|
-
? mapHTML(shown
|
|
462
|
-
|
|
463
|
-
: `<p class="undrawn"${bodyAttrs}>${esc(t('group.undrawn'))}</p>`
|
|
487
|
+
? mapHTML(shown, parent) + mapKeyHTML()
|
|
488
|
+
: `<p class="undrawn"${foldTarget(parent)}>${esc(t('group.undrawn'))}</p>`
|
|
464
489
|
return (
|
|
465
490
|
`<section class="group" style="--track:${shown.track}"${attrs}>` +
|
|
466
491
|
`<div class="group-head">${fold}<h2>${esc(groupTitle(shown.group))}</h2>` +
|
|
@@ -513,10 +538,7 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
513
538
|
}
|
|
514
539
|
|
|
515
540
|
/** 沒有選取時預設看哪一張:第一張可接手的,再不然就第一張。 */
|
|
516
|
-
const defaultPick = (): number | undefined =>
|
|
517
|
-
const first = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)[0]
|
|
518
|
-
return (first ?? issues[0])?.number
|
|
519
|
-
}
|
|
541
|
+
const defaultPick = (): number | undefined => (frontline()[0] ?? issues[0])?.number
|
|
520
542
|
|
|
521
543
|
const detailPanelHTML = (number: number | undefined): { status: string; html: string } => {
|
|
522
544
|
const issue = number === undefined ? undefined : issueAt(number)
|
|
@@ -526,42 +548,66 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
526
548
|
|
|
527
549
|
// ---- 清單 ----
|
|
528
550
|
|
|
529
|
-
/**
|
|
551
|
+
/**
|
|
552
|
+
* 主票在前、它的子票跟在後面。
|
|
553
|
+
*
|
|
554
|
+
* `parent` 是一個指標欄位,語意上就是任意深度的森林,所以這裡真的把它折成森林再深度優先
|
|
555
|
+
* 展平——**每張票只會出現一次**。把它當成「有沒有 parent 就是 root」的兩層結構、再補一條
|
|
556
|
+
* 「把漏掉的主票撿回來」的例外的話,三層鏈的中間那張會同時是子票又是主票,渲染成兩列。
|
|
557
|
+
*
|
|
558
|
+
* 篩選時祖先只要有後代入選就留著當標頭,否則子票沒了歸屬。
|
|
559
|
+
*/
|
|
530
560
|
const rowOrder = (filter: Filter): Row[] => {
|
|
531
|
-
const pass = new Set(
|
|
532
|
-
issues.filter((i) => filter === 'all' || i.status === filter).map((i) => i.number),
|
|
533
|
-
)
|
|
534
561
|
const kids = new Map<number, MapIssue[]>()
|
|
535
562
|
const roots: MapIssue[] = []
|
|
536
563
|
for (const issue of issues) {
|
|
564
|
+
// 指向沒被帶進快照的 parent,就當它自己是一枝的頂端。
|
|
537
565
|
const parent = issue.parent !== null && byNumber.has(issue.parent) ? issue.parent : null
|
|
538
566
|
if (parent === null) {
|
|
539
|
-
|
|
567
|
+
roots.push(issue)
|
|
540
568
|
continue
|
|
541
569
|
}
|
|
542
|
-
if (!pass.has(issue.number)) continue
|
|
543
570
|
const siblings = kids.get(parent) ?? []
|
|
544
571
|
siblings.push(issue)
|
|
545
572
|
kids.set(parent, siblings)
|
|
546
573
|
}
|
|
547
|
-
// 有子票入選但自己沒入選的主票,仍要出現,否則子票就沒了歸屬。
|
|
548
|
-
for (const parent of kids.keys()) {
|
|
549
|
-
const issue = issueAt(parent)
|
|
550
|
-
if (issue && !roots.includes(issue)) roots.push(issue)
|
|
551
|
-
}
|
|
552
574
|
|
|
575
|
+
const hits = (issue: MapIssue): boolean => filter === 'all' || issue.status === filter
|
|
553
576
|
const rows: Row[] = []
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
558
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)
|
|
559
603
|
return rows
|
|
560
604
|
}
|
|
561
605
|
|
|
562
606
|
const rowHTML = (row: Row): string => {
|
|
563
607
|
const issue = row.issue
|
|
564
|
-
|
|
608
|
+
// 縮排的級距在 CSS 裡,這裡只說第幾層。
|
|
609
|
+
const parent =
|
|
610
|
+
row.parent === null ? '' : ` data-parent="${row.parent}" style="--depth:${row.depth}"`
|
|
565
611
|
const kids = row.hasKids ? ' data-haskids="true"' : ''
|
|
566
612
|
const slot = row.hasKids ? `<span class="fold-slot">${foldButtonHTML(issue.number)}</span>` : ''
|
|
567
613
|
const waitsTitle =
|
|
@@ -646,5 +692,3 @@ export function viewOf(snapshot: Partial<Snapshot>) {
|
|
|
646
692
|
tabsHTML,
|
|
647
693
|
}
|
|
648
694
|
}
|
|
649
|
-
|
|
650
|
-
export type View = ReturnType<typeof viewOf>
|