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
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* 那一側(`issue-map-page.ts`)都 import 它,所以它不能碰任何一邊的專屬 API。
|
|
4
4
|
*
|
|
5
5
|
* 這裡住的是「同一份定義只有一份」的東西:狀態的五個值、一張票的形狀、分群規則、關鍵路徑,
|
|
6
|
-
*
|
|
7
|
-
* 是共用的,抄錯編不過。
|
|
6
|
+
* 以及線路圖的排版。兩側共用同一份型別,任何一邊自己抄一份都編不過。
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
/** 一張票現在的處境。同時是頁面的顏色與篩選分頁。 */
|
|
@@ -73,15 +72,36 @@ export type Group = {
|
|
|
73
72
|
export type Snapshot = {
|
|
74
73
|
readonly generatedAt: string
|
|
75
74
|
readonly repo: string
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
/**
|
|
76
|
+
* 這一次實際生效的標籤字彙與閘門。圖例照它寫,不然改了設定圖例就會說謊。
|
|
77
|
+
*
|
|
78
|
+
* `gated` 是**狀態機當下真的有沒有把這些標籤當閘門**。字彙本身一律有預設值、永遠非空,拿
|
|
79
|
+
* 它的長度去推閘門開著沒有的話,repo 還沒導入標籤時票會判成可動,頁尾卻說沒掛標籤等於未定案。
|
|
80
|
+
*/
|
|
81
|
+
readonly labels: {
|
|
82
|
+
readonly ready: readonly string[]
|
|
83
|
+
readonly unready: readonly string[]
|
|
84
|
+
readonly gated: boolean
|
|
85
|
+
}
|
|
79
86
|
readonly groups: readonly Group[]
|
|
80
87
|
/** 最長的一條依序未完成鏈,也就是最少要幾輪。 */
|
|
81
88
|
readonly criticalPath: number
|
|
82
89
|
readonly issues: readonly MapIssue[]
|
|
83
90
|
}
|
|
84
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 什麼都還沒有的快照。畫面那一側解析行內 JSON 時用它補齊缺的欄位——**缺欄位只在那一個邊界
|
|
94
|
+
* 成立**,收在這裡的話下游拿到的一律是完整的 `Snapshot`,不必每個欄位各自決定「缺了算什麼」。
|
|
95
|
+
*/
|
|
96
|
+
export const EMPTY_SNAPSHOT: Snapshot = {
|
|
97
|
+
generatedAt: '',
|
|
98
|
+
repo: '',
|
|
99
|
+
labels: { ready: [], unready: [], gated: false },
|
|
100
|
+
groups: [],
|
|
101
|
+
criticalPath: 0,
|
|
102
|
+
issues: [],
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
/**
|
|
86
106
|
* 分群:同一張主票底下的子票一群;沒有主票但跟別人有前置關係的合成一群;完全孤立的合成一群。
|
|
87
107
|
* 一張圖畫一群。
|
|
@@ -175,16 +195,36 @@ export type Edge = { readonly from: number; readonly to: number }
|
|
|
175
195
|
export type Layout = {
|
|
176
196
|
/** 每一條線由前到後的站。只有一站的線不標線名。 */
|
|
177
197
|
readonly tracks: readonly (readonly number[])[]
|
|
178
|
-
/**
|
|
198
|
+
/** 孤立的票排成幾列月台。月台接在最後一條線下面,起點就是 `tracks.length`。 */
|
|
179
199
|
readonly islandRows: number
|
|
180
|
-
/** 月台從第幾列開始。 */
|
|
181
|
-
readonly islandFrom: number
|
|
182
200
|
readonly xy: ReadonlyMap<number, Point>
|
|
183
201
|
readonly edges: readonly Edge[]
|
|
184
202
|
readonly width: number
|
|
185
203
|
readonly height: number
|
|
186
204
|
}
|
|
187
205
|
|
|
206
|
+
/** 月台一列至少幾站。票很少時不要排成細細一條。 */
|
|
207
|
+
const ISLAND_MIN_PER_ROW = 4
|
|
208
|
+
/**
|
|
209
|
+
* 月台一列最多幾站。再寬下去橫向要捲很遠,而月台上的左右位置本來就不帶意義——寧可多幾列。
|
|
210
|
+
*/
|
|
211
|
+
const ISLAND_MAX_PER_ROW = 12
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 一組票內部的阻擋邊。**什麼算一條邊只有這一份定義**——排版照它畫線,頁面也照它判斷這一組畫
|
|
215
|
+
* 出來到底有沒有線;各算一次的話會出現「說要畫圖、畫出來卻沒有線」。
|
|
216
|
+
*/
|
|
217
|
+
export function edgesWithin(members: readonly MapIssue[]): Edge[] {
|
|
218
|
+
const inGroup = new Set(members.map((m) => m.number))
|
|
219
|
+
const edges: Edge[] = []
|
|
220
|
+
for (const m of members) {
|
|
221
|
+
for (const from of m.blockedBy) {
|
|
222
|
+
if (inGroup.has(from)) edges.push({ from, to: m.number })
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return edges
|
|
226
|
+
}
|
|
227
|
+
|
|
188
228
|
/**
|
|
189
229
|
* 把一組票排成線路圖。
|
|
190
230
|
*
|
|
@@ -196,19 +236,12 @@ export type Layout = {
|
|
|
196
236
|
* 會變成十二條單站線。
|
|
197
237
|
*/
|
|
198
238
|
export function layoutOf(members: readonly MapIssue[]): Layout {
|
|
199
|
-
const
|
|
200
|
-
const preds = new Map(
|
|
201
|
-
|
|
202
|
-
)
|
|
239
|
+
const edges = edgesWithin(members)
|
|
240
|
+
const preds = new Map<number, number[]>(members.map((m) => [m.number, []]))
|
|
241
|
+
for (const edge of edges) preds.get(edge.to)?.push(edge.from)
|
|
203
242
|
const predsOf = (n: number): readonly number[] => preds.get(n) ?? []
|
|
204
243
|
|
|
205
|
-
const hasEdge = new Set
|
|
206
|
-
for (const m of members) {
|
|
207
|
-
for (const from of predsOf(m.number)) {
|
|
208
|
-
hasEdge.add(m.number)
|
|
209
|
-
hasEdge.add(from)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
244
|
+
const hasEdge = new Set(edges.flatMap((edge) => [edge.from, edge.to]))
|
|
212
245
|
const wired = members.filter((m) => hasEdge.has(m.number))
|
|
213
246
|
const island = members.filter((m) => !hasEdge.has(m.number))
|
|
214
247
|
|
|
@@ -235,7 +268,9 @@ export function layoutOf(members: readonly MapIssue[]): Layout {
|
|
|
235
268
|
}
|
|
236
269
|
|
|
237
270
|
const depth = wired.length ? Math.max(...wired.map((m) => levelOf(m.number))) : 1
|
|
238
|
-
|
|
271
|
+
// 月台排成接近正方形,不然沒有阻擋關係的 repo 會把整批票疊成一條幾千 px 高的直條。
|
|
272
|
+
const squarish = Math.ceil(Math.sqrt(island.length))
|
|
273
|
+
const perRow = Math.max(depth, Math.min(squarish, ISLAND_MAX_PER_ROW), ISLAND_MIN_PER_ROW)
|
|
239
274
|
const xy = new Map<number, Point>()
|
|
240
275
|
tracks.forEach((chain, index) => {
|
|
241
276
|
for (const n of chain) {
|
|
@@ -250,15 +285,9 @@ export function layoutOf(members: readonly MapIssue[]): Layout {
|
|
|
250
285
|
})
|
|
251
286
|
const islandRows = Math.ceil(island.length / perRow)
|
|
252
287
|
|
|
253
|
-
const edges: Edge[] = []
|
|
254
|
-
for (const m of members) {
|
|
255
|
-
for (const from of predsOf(m.number)) edges.push({ from, to: m.number })
|
|
256
|
-
}
|
|
257
|
-
|
|
258
288
|
return {
|
|
259
289
|
tracks,
|
|
260
290
|
islandRows,
|
|
261
|
-
islandFrom: tracks.length,
|
|
262
291
|
xy,
|
|
263
292
|
edges,
|
|
264
293
|
width: MAP.gutter + perRow * MAP.step + MAP.rightPad,
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 開發地圖的畫面。`issue-map.ts` 用 `Bun.build` 把這支打包成一段 script 塞進樣板,所以它
|
|
3
|
+
* **只能碰瀏覽器的東西**,不能 import Bun 的 API。
|
|
4
|
+
*
|
|
5
|
+
* 標記全部在 `issue-map-view.ts`,而且建置時就已經用同一批函式畫過一次寫進檔案了——這一支只在
|
|
6
|
+
* 換語言、換篩選、選取、收合時重畫。所以沒有 JS 的環境看到的是同一份頁面,只是不能互動。
|
|
7
|
+
*
|
|
8
|
+
* 這裡剩下的是 DOM 與事件:讀寫 localStorage 的兩個偏好(語言、收合)、事件代理、選取狀態。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DEFAULT_LOCALE, isLocale, type Locale, locale, setLocale, t } from './issue-map-i18n.ts'
|
|
12
|
+
import { EMPTY_SNAPSHOT, type Snapshot } from './issue-map-model.ts'
|
|
13
|
+
import { type Filter, langOptionsHTML, viewOf } from './issue-map-view.ts'
|
|
14
|
+
|
|
15
|
+
/** 樣板保證這些節點存在。找不到就是樣板被改壞了,早點喊比畫出半張圖好。 */
|
|
16
|
+
function pick(id: string): HTMLElement {
|
|
17
|
+
const node = document.getElementById(id)
|
|
18
|
+
if (!node) throw new Error(`樣板缺少 #${id}`)
|
|
19
|
+
return node
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 行內 JSON 是整份快照唯一可能殘缺的地方(沒有資料、被截斷)。缺的欄位在這裡一次補齊,下游
|
|
24
|
+
* 拿到的一律是完整的 `Snapshot`——不然每個用到快照的地方都要自己決定「缺了算什麼」。
|
|
25
|
+
*/
|
|
26
|
+
function readSnapshot(): Snapshot {
|
|
27
|
+
const parsed = JSON.parse(pick('issue-map-data').textContent || '{}') as Partial<Snapshot>
|
|
28
|
+
return { ...EMPTY_SNAPSHOT, ...parsed }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const snapshot = readSnapshot()
|
|
32
|
+
const view = viewOf(snapshot)
|
|
33
|
+
const repo = snapshot.repo
|
|
34
|
+
|
|
35
|
+
const detail = pick('detail')
|
|
36
|
+
const rowsEl = pick('rows')
|
|
37
|
+
const groupsEl = pick('groups')
|
|
38
|
+
const tabsEl = pick('tabs')
|
|
39
|
+
|
|
40
|
+
let selected: number | null = null
|
|
41
|
+
let filter: Filter = 'all'
|
|
42
|
+
|
|
43
|
+
// ---- 偏好 ----
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 偏好記在瀏覽器。**讀不到、寫不進都不是錯誤**:無痕視窗與擋掉儲存的設定裡 `localStorage` 會
|
|
47
|
+
* 直接丟例外,那時偏好只在這一次有效,畫面照樣是完整的——所以存取一律收在這兩支裡。
|
|
48
|
+
*/
|
|
49
|
+
function readStored(key: string): string | null {
|
|
50
|
+
try {
|
|
51
|
+
return localStorage.getItem(key)
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeStored(key: string, value: string): void {
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(key, value)
|
|
60
|
+
} catch {
|
|
61
|
+
// 存不了就只在這一次有效。
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- 語言 ----
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 語言記在瀏覽器、而且不分 repo——同一個人看好幾個 repo 的地圖,語言是他的偏好,不是某個專案
|
|
69
|
+
* 的設定。讀不到(無痕、封鎖)或存的是舊值就用預設的英文。
|
|
70
|
+
*
|
|
71
|
+
* 刻意不看 `navigator.language`:這一頁的預設語言是英文,猜錯了反而要每次進來都改回去。
|
|
72
|
+
*/
|
|
73
|
+
const LOCALE_KEY = 'issue-map:locale'
|
|
74
|
+
|
|
75
|
+
function readLocale(): Locale {
|
|
76
|
+
const saved = readStored(LOCALE_KEY)
|
|
77
|
+
return isLocale(saved) ? saved : DEFAULT_LOCALE
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 語言選單只做一次;換語言是整頁重畫,選單自己不重建,不然焦點會掉。 */
|
|
81
|
+
function mountLangPicker(): void {
|
|
82
|
+
const picker = pick('lang')
|
|
83
|
+
if (!(picker instanceof HTMLSelectElement)) throw new Error('#lang 不是 select')
|
|
84
|
+
picker.innerHTML = langOptionsHTML()
|
|
85
|
+
picker.value = locale()
|
|
86
|
+
picker.addEventListener('change', () => {
|
|
87
|
+
if (!isLocale(picker.value)) return
|
|
88
|
+
setLocale(picker.value)
|
|
89
|
+
writeStored(LOCALE_KEY, picker.value)
|
|
90
|
+
render()
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- 收合狀態 ----
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 收合狀態只有一份,地圖與清單都讀它、都能改它。每個瀏覽器記自己的;讀不到(無痕、封鎖)
|
|
98
|
+
* 就當成全部展開。
|
|
99
|
+
*/
|
|
100
|
+
const COLLAPSE_KEY = `issue-map:collapsed:${repo}`
|
|
101
|
+
|
|
102
|
+
function readFolded(): Set<string> {
|
|
103
|
+
try {
|
|
104
|
+
return new Set(JSON.parse(readStored(COLLAPSE_KEY) || '[]') as string[])
|
|
105
|
+
} catch {
|
|
106
|
+
return new Set()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const folded = readFolded()
|
|
111
|
+
|
|
112
|
+
function isFolded(parent: number): boolean {
|
|
113
|
+
return folded.has(String(parent))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 掛在某張票底下的東西該不該藏起來。
|
|
118
|
+
*
|
|
119
|
+
* 看的是**整條祖先鏈**,不只直屬主票——三層的鏈收起最上面那張時,第三層的 `data-parent` 指的
|
|
120
|
+
* 是第二層,只比對直屬的話它會單獨留在畫面上。
|
|
121
|
+
*/
|
|
122
|
+
function foldedAnywhere(parent: number): boolean {
|
|
123
|
+
const seen = new Set<number>()
|
|
124
|
+
for (let at: number | null = parent; at !== null && !seen.has(at);) {
|
|
125
|
+
if (isFolded(at)) return true
|
|
126
|
+
seen.add(at)
|
|
127
|
+
at = view.issueAt(at)?.parent ?? null
|
|
128
|
+
}
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function setFolded(parent: number, shut: boolean): void {
|
|
133
|
+
if (shut) folded.add(String(parent))
|
|
134
|
+
else folded.delete(String(parent))
|
|
135
|
+
writeStored(COLLAPSE_KEY, JSON.stringify([...folded]))
|
|
136
|
+
paintFolded()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 收合只在這裡畫一次:列、就地展開的細節、地圖本體,凡是掛了 data-parent 的都聽它。收合鈕的
|
|
141
|
+
* 樣子也在這裡補上——建置時畫出來的鈕是空的,因為那時還不知道這個瀏覽器記了什麼。
|
|
142
|
+
*/
|
|
143
|
+
function paintFolded(): void {
|
|
144
|
+
for (const node of document.querySelectorAll<HTMLElement>('[data-parent]')) {
|
|
145
|
+
node.hidden = foldedAnywhere(Number(node.dataset.parent))
|
|
146
|
+
}
|
|
147
|
+
for (const section of document.querySelectorAll<HTMLElement>('section.group[data-fold]')) {
|
|
148
|
+
section.dataset.folded = String(isFolded(Number(section.dataset.fold)))
|
|
149
|
+
}
|
|
150
|
+
for (const button of document.querySelectorAll<HTMLElement>('.fold[data-fold-for]')) {
|
|
151
|
+
const parent = Number(button.dataset.foldFor)
|
|
152
|
+
const open = !isFolded(parent)
|
|
153
|
+
button.setAttribute('aria-expanded', String(open))
|
|
154
|
+
button.setAttribute(
|
|
155
|
+
'aria-label',
|
|
156
|
+
open ? t('fold.collapse', { n: parent }) : t('fold.expand', { n: parent }),
|
|
157
|
+
)
|
|
158
|
+
button.textContent = open ? '−' : '+'
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- 選取 ----
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 選一張票。再點同一張不會取消——在清單上你往往只是想再看一次,取消掉反而要重新找。要清掉
|
|
166
|
+
* 按 Esc。
|
|
167
|
+
*/
|
|
168
|
+
function select(number: number): void {
|
|
169
|
+
selected = number
|
|
170
|
+
paintSelection()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function clearSelection(): void {
|
|
174
|
+
selected = null
|
|
175
|
+
closeExpander()
|
|
176
|
+
paintSelection()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function paintSelection(): void {
|
|
180
|
+
const lit = selected === null ? null : view.chainOf(selected)
|
|
181
|
+
for (const svg of document.querySelectorAll<SVGSVGElement>('svg')) {
|
|
182
|
+
svg.classList.toggle('has-selection', selected !== null)
|
|
183
|
+
for (const node of svg.querySelectorAll<SVGElement>('.node')) {
|
|
184
|
+
const number = Number(node.dataset.number)
|
|
185
|
+
node.classList.toggle('lit', lit !== null && lit.has(number))
|
|
186
|
+
node.classList.toggle('selected', number === selected)
|
|
187
|
+
}
|
|
188
|
+
for (const edge of svg.querySelectorAll<SVGElement>('.edge')) {
|
|
189
|
+
const from = Number(edge.dataset.from)
|
|
190
|
+
const to = Number(edge.dataset.to)
|
|
191
|
+
edge.classList.toggle('lit', lit !== null && lit.has(from) && lit.has(to))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-number]')) {
|
|
195
|
+
row.classList.toggle('selected', Number(row.dataset.number) === selected)
|
|
196
|
+
}
|
|
197
|
+
renderDetail(selected ?? view.defaultPick())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderDetail(number: number | undefined): void {
|
|
201
|
+
const panel = view.detailPanelHTML(number)
|
|
202
|
+
detail.dataset.status = panel.status
|
|
203
|
+
detail.innerHTML = panel.html
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- 就地展開 ----
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 清單的細節就在被點的那一列底下展開,不把畫面捲到上面的面板——連著看三張票時,視線不用
|
|
210
|
+
* 來回跑。
|
|
211
|
+
*/
|
|
212
|
+
function closeExpander(): void {
|
|
213
|
+
rowsEl.querySelector('.detail.expand')?.remove()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function openExpander(row: HTMLElement, number: number): void {
|
|
217
|
+
closeExpander()
|
|
218
|
+
const issue = view.issueAt(number)
|
|
219
|
+
if (!issue) return
|
|
220
|
+
const panel = document.createElement('div')
|
|
221
|
+
panel.className = 'detail expand'
|
|
222
|
+
panel.dataset.status = issue.status
|
|
223
|
+
panel.dataset.forNumber = String(number)
|
|
224
|
+
// 掛上 data-parent,主票收起來時 paintFolded 會一起把它藏掉。
|
|
225
|
+
if (row.dataset.parent) panel.dataset.parent = row.dataset.parent
|
|
226
|
+
panel.innerHTML = view.detailHTML(number)
|
|
227
|
+
row.insertAdjacentElement('afterend', panel)
|
|
228
|
+
paintFolded()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function pickRow(row: HTMLElement): void {
|
|
232
|
+
const number = Number(row.dataset.number)
|
|
233
|
+
const already = rowsEl.querySelector(`.detail.expand[data-for-number="${number}"]`)
|
|
234
|
+
// 上面的面板換一張票會改變自己的高度,把清單整段推走。先記下這一列在畫面上的位置,改完
|
|
235
|
+
// 再補回去,手指底下的那一列就不會跑。
|
|
236
|
+
const before = row.getBoundingClientRect().top
|
|
237
|
+
select(number)
|
|
238
|
+
if (already) closeExpander()
|
|
239
|
+
else openExpander(row, number)
|
|
240
|
+
const shift = row.getBoundingClientRect().top - before
|
|
241
|
+
if (shift) window.scrollBy({ top: shift, behavior: 'instant' })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- 複製 ----
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 複製到剪貼簿。`navigator.clipboard` 在非安全來源沒有,而就算有也可能被權限擋掉(沒有使用者
|
|
248
|
+
* 手勢、或瀏覽器設定),所以失敗一律退回選取再 execCommand。
|
|
249
|
+
*/
|
|
250
|
+
function legacyCopy(text: string): Promise<void> {
|
|
251
|
+
const box = document.createElement('textarea')
|
|
252
|
+
box.value = text
|
|
253
|
+
box.setAttribute('readonly', '')
|
|
254
|
+
box.style.cssText = 'position:fixed;top:-100px;opacity:0'
|
|
255
|
+
document.body.appendChild(box)
|
|
256
|
+
box.select()
|
|
257
|
+
const ok = document.execCommand('copy')
|
|
258
|
+
box.remove()
|
|
259
|
+
return ok ? Promise.resolve() : Promise.reject(new Error('execCommand copy failed'))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function copyText(text: string): Promise<void> {
|
|
263
|
+
if (!navigator.clipboard || !window.isSecureContext) return legacyCopy(text)
|
|
264
|
+
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function flash(label: HTMLElement, text: string, ms: number, then: () => void): void {
|
|
268
|
+
label.textContent = text
|
|
269
|
+
setTimeout(then, ms)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function handleCopy(button: HTMLElement): void {
|
|
273
|
+
const label = button.querySelector<HTMLElement>('.copy-text')
|
|
274
|
+
const command = button.dataset.copy
|
|
275
|
+
if (!label || !command) return
|
|
276
|
+
const was = label.textContent ?? ''
|
|
277
|
+
copyText(command).then(
|
|
278
|
+
() => {
|
|
279
|
+
button.dataset.copied = 'true'
|
|
280
|
+
flash(label, t('copy.done'), 1200, () => {
|
|
281
|
+
delete button.dataset.copied
|
|
282
|
+
label.textContent = was
|
|
283
|
+
})
|
|
284
|
+
},
|
|
285
|
+
() => flash(label, t('copy.fail'), 1600, () => (label.textContent = was)),
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- 事件 ----
|
|
290
|
+
|
|
291
|
+
function elementAt(ev: Event): Element | null {
|
|
292
|
+
return ev.target instanceof Element ? ev.target : null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 全部走委派:標記是建置時畫好的,重畫也只是換 innerHTML,沒有可以逐一掛監聽的時機。
|
|
296
|
+
document.addEventListener('click', (ev) => {
|
|
297
|
+
const target = elementAt(ev)
|
|
298
|
+
if (!target) return
|
|
299
|
+
|
|
300
|
+
const fold = target.closest<HTMLElement>('.fold[data-fold-for]')
|
|
301
|
+
if (fold) {
|
|
302
|
+
const parent = Number(fold.dataset.foldFor)
|
|
303
|
+
setFolded(parent, !isFolded(parent))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const copy = target.closest<HTMLElement>('.copy')
|
|
308
|
+
if (copy) {
|
|
309
|
+
// 複製是自己一件事,不要順手把那一列也選起來。
|
|
310
|
+
ev.stopPropagation()
|
|
311
|
+
handleCopy(copy)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const tab = target.closest<HTMLElement>('.tab[data-filter]')
|
|
316
|
+
if (tab) {
|
|
317
|
+
filter = (tab.dataset.filter ?? 'all') as Filter
|
|
318
|
+
renderRows()
|
|
319
|
+
paintSelection()
|
|
320
|
+
const back =
|
|
321
|
+
selected === null
|
|
322
|
+
? null
|
|
323
|
+
: rowsEl.querySelector<HTMLElement>(`.row[data-number="${selected}"]`)
|
|
324
|
+
if (back && selected !== null) openExpander(back, selected)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 點站看詳細;要開 GitHub 用 ⌘/Ctrl 點,或詳細面板裡的連結。
|
|
329
|
+
const node = target.closest<HTMLElement>('.node[data-number]')
|
|
330
|
+
if (node) {
|
|
331
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey) return
|
|
332
|
+
ev.preventDefault()
|
|
333
|
+
const number = Number(node.dataset.number)
|
|
334
|
+
// 再點同一站就取消,跟點空白處一樣——不然亮起來之後只剩 Esc 能收,那沒人找得到。
|
|
335
|
+
if (selected === number) clearSelection()
|
|
336
|
+
else select(number)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 點圖上的空白處就取消亮線。
|
|
341
|
+
if (target.closest('.map-wrap')) {
|
|
342
|
+
clearSelection()
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (target.closest('a')) return
|
|
347
|
+
const row = target.closest<HTMLElement>('.row[data-number]')
|
|
348
|
+
if (row) pickRow(row)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
rowsEl.addEventListener('keydown', (ev) => {
|
|
352
|
+
const target = elementAt(ev)
|
|
353
|
+
const row = target?.closest<HTMLElement>('.row[data-number]')
|
|
354
|
+
if (!row) return
|
|
355
|
+
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
356
|
+
ev.preventDefault()
|
|
357
|
+
pickRow(row)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return
|
|
361
|
+
ev.preventDefault()
|
|
362
|
+
const rows = [...rowsEl.querySelectorAll<HTMLElement>('.row[data-number]:not([hidden])')]
|
|
363
|
+
const next = rows[rows.indexOf(row) + (ev.key === 'ArrowDown' ? 1 : -1)]
|
|
364
|
+
next?.focus()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
document.addEventListener('keydown', (ev) => {
|
|
368
|
+
if (ev.key === 'Escape') clearSelection()
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// ---- 重畫 ----
|
|
372
|
+
|
|
373
|
+
function renderRows(): void {
|
|
374
|
+
const rows = view.rowsHTML(filter)
|
|
375
|
+
pick('list-title').textContent = t('list.title')
|
|
376
|
+
pick('list-sub').textContent = t('list.count', { n: rows.shown })
|
|
377
|
+
rowsEl.innerHTML = rows.html
|
|
378
|
+
tabsEl.innerHTML = view.tabsHTML(filter)
|
|
379
|
+
paintFolded()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 整頁重畫。換語言就是走這裡——每一段都會先清掉自己那一塊,所以重畫一次不會留下上一種語言的
|
|
384
|
+
* 殘骸。選取與收合是狀態不是文字,重畫時保留。
|
|
385
|
+
*/
|
|
386
|
+
function render(): void {
|
|
387
|
+
const heading = view.title()
|
|
388
|
+
pick('page-title').textContent = heading
|
|
389
|
+
document.title = heading
|
|
390
|
+
document.documentElement.lang = locale()
|
|
391
|
+
pick('lang').setAttribute('aria-label', t('lang.label'))
|
|
392
|
+
pick('eyebrow').textContent = view.eyebrow()
|
|
393
|
+
pick('lede').innerHTML = view.lede()
|
|
394
|
+
pick('stats').innerHTML = view.statsHTML()
|
|
395
|
+
|
|
396
|
+
// 三段都是逃脫過的 HTML,跟建置時填進樣板的是同一批字串——用 textContent 塞會把逃脫顯示出來。
|
|
397
|
+
const foot = view.footerHTML()
|
|
398
|
+
pick('foot-truth').innerHTML = foot.truth
|
|
399
|
+
pick('foot-refresh').innerHTML = foot.refresh
|
|
400
|
+
pick('foot-config').innerHTML = foot.config
|
|
401
|
+
|
|
402
|
+
groupsEl.innerHTML = view.groupsHTML()
|
|
403
|
+
tabsEl.setAttribute('aria-label', t('tabs.aria'))
|
|
404
|
+
renderRows()
|
|
405
|
+
// paintSelection 收尾會畫詳細,沒有選取時它自己退回預設那一張。
|
|
406
|
+
paintSelection()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
setLocale(readLocale())
|
|
410
|
+
mountLangPicker()
|
|
411
|
+
render()
|
|
@@ -6,26 +6,28 @@
|
|
|
6
6
|
* 這是 package.json 的預設 bin,所以在**要看的那個 repo** 裡直接跑就會畫那個 repo:
|
|
7
7
|
* bunx issue-map@latest
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 起來之後直接開瀏覽器,`ISSUE_MAP_OPEN=0` 可以關掉(`--watch` 的開發模式靠它,否則每存一次
|
|
10
|
+
* 檔就多一個分頁)。
|
|
11
|
+
*
|
|
12
|
+
* 預設不綁固定 port(`port: 0`,交給 OS 挑),所以在好幾個 repo 裡同時跑不會互相撞;實際網址
|
|
13
|
+
* 跟正在畫的目錄一起印在啟動訊息裡。設 `ISSUE_MAP_PORT` 可以固定,那時撞到就直接失敗而不偷
|
|
14
|
+
* 偷換一個——指定了還被換掉,書籤和反向代理都會對不上。
|
|
11
15
|
*
|
|
12
16
|
* 在這個 repo 裡開發時:
|
|
13
|
-
* bun run issue-map:serve #
|
|
14
|
-
* ISSUE_MAP_PORT=
|
|
17
|
+
* bun run issue-map:serve # 隨機 port,不自動開
|
|
18
|
+
* ISSUE_MAP_PORT=4747 bun run issue-map:serve # 固定網址,--watch 重啟後還是同一個
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
import { spawnSync } from 'bun'
|
|
18
22
|
|
|
19
|
-
import { describe,
|
|
23
|
+
import { describe, renderDocument, takeSnapshot } from './issue-map.ts'
|
|
20
24
|
|
|
21
|
-
// `PORT` 是 Claude 桌面 app 的 launch.json 在 autoPort 換 port
|
|
22
|
-
|
|
25
|
+
// `PORT` 是 Claude 桌面 app 的 launch.json 在 autoPort 換 port 時塞進來的。都沒設就是 0:
|
|
26
|
+
// 由 OS 指派,`server.port` 才是真的在聽的那個。
|
|
27
|
+
const PORT = Number(process.env.ISSUE_MAP_PORT ?? process.env.PORT ?? 0)
|
|
23
28
|
const OPEN = process.env.ISSUE_MAP_OPEN !== '0'
|
|
24
29
|
|
|
25
|
-
/**
|
|
26
|
-
* 開系統預設瀏覽器。**打不開不算失敗**:server 已經起來了,印出網址讓人自己開就好——把它
|
|
27
|
-
* 當錯誤收掉會讓「地圖其實好好地跑著」這件事被一個無關的問題蓋掉。
|
|
28
|
-
*/
|
|
30
|
+
/** 開系統預設瀏覽器。**打不開不算失敗**:server 已經起來了,印出網址讓人自己開就好。 */
|
|
29
31
|
function openInBrowser(url: string): void {
|
|
30
32
|
const command =
|
|
31
33
|
process.platform === 'darwin'
|
|
@@ -41,14 +43,25 @@ function openInBrowser(url: string): void {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 同時抵達的請求共用同一次抓取。
|
|
48
|
+
*
|
|
49
|
+
* 一次重新整理按兩下、或開著兩個分頁,本來會各自跑一趟完整的 GitHub 抓取;它們要的是同一刻的
|
|
50
|
+
* 狀態,讓後到的等前一趟就好。抓完就清掉,所以「每次重新整理都是最新的」沒有變。
|
|
51
|
+
*/
|
|
52
|
+
let inFlight: Promise<string> | null = null
|
|
53
|
+
|
|
44
54
|
async function page(): Promise<string> {
|
|
45
|
-
const snapshot = takeSnapshot()
|
|
55
|
+
const snapshot = await takeSnapshot()
|
|
46
56
|
console.log(describe(snapshot))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
return renderDocument(snapshot)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pageShared(): Promise<string> {
|
|
61
|
+
inFlight ??= page().finally(() => {
|
|
62
|
+
inFlight = null
|
|
63
|
+
})
|
|
64
|
+
return inFlight
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
const server = Bun.serve({
|
|
@@ -57,7 +70,7 @@ const server = Bun.serve({
|
|
|
57
70
|
const { pathname } = new URL(request.url)
|
|
58
71
|
if (pathname !== '/') return new Response(null, { status: 404 })
|
|
59
72
|
try {
|
|
60
|
-
return new Response(await
|
|
73
|
+
return new Response(await pageShared(), {
|
|
61
74
|
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
62
75
|
})
|
|
63
76
|
} catch (error) {
|
|
@@ -72,5 +85,6 @@ const server = Bun.serve({
|
|
|
72
85
|
})
|
|
73
86
|
|
|
74
87
|
const url = `http://localhost:${server.port}`
|
|
75
|
-
console.log(`Dev map
|
|
88
|
+
console.log(`Dev map for ${process.cwd()}`)
|
|
89
|
+
console.log(` ${url} (every refresh re-fetches from GitHub)`)
|
|
76
90
|
if (OPEN) openInBrowser(url)
|