@pghoya2956/livemap 1.0.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.
@@ -0,0 +1,104 @@
1
+ Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
2
+ with Reserved Font Name 'Pretendard'.
3
+
4
+ Copyright 2014-2021 Adobe (http://www.adobe.com/),
5
+ with Reserved Font Name 'Source'.
6
+ Source is a trademark of Adobe in the United States and/or other countries.
7
+
8
+ Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter),
9
+ with Reserved Font Name 'Inter'.
10
+
11
+ Copyright 2021 The M+ FONTS Project Authors (https://github.com/coz-m/MPLUS_FONTS),
12
+ with Reserved Font Name 'M PLUS 1'.
13
+
14
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
15
+ This license is copied below, and is also available with a FAQ at:
16
+ https://scripts.sil.org/OFL
17
+
18
+
19
+ -----------------------------------------------------------
20
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
21
+ -----------------------------------------------------------
22
+
23
+ PREAMBLE
24
+ The goals of the Open Font License (OFL) are to stimulate worldwide
25
+ development of collaborative font projects, to support the font creation
26
+ efforts of academic and linguistic communities, and to provide a free and
27
+ open framework in which fonts may be shared and improved in partnership
28
+ with others.
29
+
30
+ The OFL allows the licensed fonts to be used, studied, modified and
31
+ redistributed freely as long as they are not sold by themselves. The
32
+ fonts, including any derivative works, can be bundled, embedded,
33
+ redistributed and/or sold with any software provided that any reserved
34
+ names are not used by derivative works. The fonts and derivatives,
35
+ however, cannot be released under any other type of license. The
36
+ requirement for fonts to remain under this license does not apply
37
+ to any document created using the fonts or their derivatives.
38
+
39
+ DEFINITIONS
40
+ "Font Software" refers to the set of files released by the Copyright
41
+ Holder(s) under this license and clearly marked as such. This may
42
+ include source files, build scripts and documentation.
43
+
44
+ "Reserved Font Name" refers to any names specified as such after the
45
+ copyright statement(s).
46
+
47
+ "Original Version" refers to the collection of Font Software components as
48
+ distributed by the Copyright Holder(s).
49
+
50
+ "Modified Version" refers to any derivative made by adding to, deleting,
51
+ or substituting -- in part or in whole -- any of the components of the
52
+ Original Version, by changing formats or by porting the Font Software to a
53
+ new environment.
54
+
55
+ "Author" refers to any designer, engineer, programmer, technical
56
+ writer or other person who contributed to the Font Software.
57
+
58
+ PERMISSION & CONDITIONS
59
+ Permission is hereby granted, free of charge, to any person obtaining
60
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
61
+ redistribute, and sell modified and unmodified copies of the Font
62
+ Software, subject to the following conditions:
63
+
64
+ 1) Neither the Font Software nor any of its individual components,
65
+ in Original or Modified Versions, may be sold by itself.
66
+
67
+ 2) Original or Modified Versions of the Font Software may be bundled,
68
+ redistributed and/or sold with any software, provided that each copy
69
+ contains the above copyright notice and this license. These can be
70
+ included either as stand-alone text files, human-readable headers or
71
+ in the appropriate machine-readable metadata fields within text or
72
+ binary files as long as those fields can be easily viewed by the user.
73
+
74
+ 3) No Modified Version of the Font Software may use the Reserved Font
75
+ Name(s) unless explicit written permission is granted by the corresponding
76
+ Copyright Holder. This restriction only applies to the primary font name as
77
+ presented to the users.
78
+
79
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
80
+ Software shall not be used to promote, endorse or advertise any
81
+ Modified Version, except to acknowledge the contribution(s) of the
82
+ Copyright Holder(s) and the Author(s) or with their explicit written
83
+ permission.
84
+
85
+ 5) The Font Software, modified or unmodified, in part or in whole,
86
+ must be distributed entirely under this license, and must not be
87
+ distributed under any other license. The requirement for fonts to
88
+ remain under this license does not apply to any document created
89
+ using the Font Software.
90
+
91
+ TERMINATION
92
+ This license becomes null and void if any of the above conditions are
93
+ not met.
94
+
95
+ DISCLAIMER
96
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
97
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
98
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
99
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
100
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
101
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
102
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
103
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
104
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,25 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>상황판</title>
7
+ <!-- 서빙 CSP(default-src 'self')에 맞춰 스타일·스크립트·서체는 같은 출처의 /map/ 아래 파일만 쓴다. 인라인은 넣지 않는다. -->
8
+ <link rel="stylesheet" href="/map/map.css">
9
+ </head>
10
+ <body>
11
+
12
+ <div class="app">
13
+ <aside class="side">
14
+ <div class="brand"><span id="brandName">상황판</span><small id="brandSha"></small></div>
15
+ <nav class="nav" id="nav"></nav>
16
+ <div class="foot" id="sideFoot"></div>
17
+ </aside>
18
+ <main class="main">
19
+ <div class="head"><h1 id="title"></h1><span class="sub" id="subtitle"></span><div class="meta" id="meta"></div></div>
20
+ <div id="view"><p class="empty">불러오는 중…</p></div>
21
+ </main>
22
+ </div>
23
+ <script src="/map/map.js"></script>
24
+ </body>
25
+ </html>
package/site/map.css ADDED
@@ -0,0 +1,167 @@
1
+ @font-face{font-family:'Pretendard Variable';font-weight:45 920;font-style:normal;font-display:swap;src:url('fonts/PretendardVariable.woff2') format('woff2-variations')}
2
+
3
+ :root{
4
+ --bg:#f4f5f6; --card:#ffffff; --ink:#222222; --muted:#717171; --line:#dddddd; --soft:#eef1f4; --side:#0a2540; --side-ink:#d6e0ea; --side-on:#ffffff;
5
+ --navy:#0a2540; --accent:#0a2540; --red:#d92e40; --red-soft:#fde8ea; --live:#1f8a4c; --live-soft:#e3f3e8; --mock:#b86e00; --mock-soft:#fff1dc; --plan:#5b6b7c; --plan-soft:#e9edf1; --new:#f23d4e;
6
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; --sans:'Pretendard Variable',-apple-system,BlinkMacSystemFont,'Segoe UI','Apple SD Gothic Neo','Noto Sans KR',sans-serif;
7
+ }
8
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0f1418;--card:#171d23;--ink:#e6e9ec;--muted:#9aa4ad;--line:#2a333c;--soft:#1e262e;--side:#0b1219;--side-ink:#8fa3b8;--side-on:#fff;--navy:#8fb3e0;--accent:#8fb3e0;--red:#ff6b7a;--red-soft:#3a1c22;--live:#5ccf86;--live-soft:#16301f;--mock:#f0b25a;--mock-soft:#3a2a12;--plan:#a4b0bb;--plan-soft:#242c34}}
9
+ :root[data-theme="dark"]{--bg:#0f1418;--card:#171d23;--ink:#e6e9ec;--muted:#9aa4ad;--line:#2a333c;--soft:#1e262e;--side:#0b1219;--side-ink:#8fa3b8;--side-on:#fff;--navy:#8fb3e0;--accent:#8fb3e0;--red:#ff6b7a;--red-soft:#3a1c22;--live:#5ccf86;--live-soft:#16301f;--mock:#f0b25a;--mock-soft:#3a2a12;--plan:#a4b0bb;--plan-soft:#242c34}
10
+ *{box-sizing:border-box}
11
+ html,body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:13.5px;line-height:1.45}
12
+ a{color:inherit}
13
+ code{font-family:var(--mono);font-size:.92em;background:var(--soft);padding:0 4px;border-radius:4px}
14
+ .app{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
15
+ @media (max-width:900px){.app{grid-template-columns:1fr}}
16
+ .side{background:var(--side);color:var(--side-ink);padding:16px 12px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:6px}
17
+ @media (max-width:900px){.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;padding:10px 16px}}
18
+ .brand{color:var(--side-on);font-weight:700;font-size:15px;margin-bottom:6px;display:flex;flex-direction:column}
19
+ .brand small{font-weight:400;font-size:11px;color:var(--side-ink);font-family:var(--mono)}
20
+ .nav{display:flex;flex-direction:column;gap:2px}
21
+ @media (max-width:900px){.nav{flex-direction:row;flex-wrap:wrap;gap:4px;margin-left:auto}}
22
+ .nav a{display:flex;justify-content:space-between;align-items:center;gap:8px;text-decoration:none;padding:7px 10px;border-radius:8px;font-size:13px;color:var(--side-ink)}
23
+ .nav a:hover{background:rgba(255,255,255,.06);color:var(--side-on)}
24
+ .nav a.on{background:rgba(255,255,255,.12);color:var(--side-on);font-weight:500}
25
+ .nav a b{font-family:var(--mono);font-weight:400;font-size:11px;opacity:.7}
26
+ .side .foot{margin-top:auto;font-size:11px;color:var(--side-ink);opacity:.8;line-height:1.6}
27
+ @media (max-width:900px){.side .foot{display:none}}
28
+ .main{padding:10px 16px 24px;min-width:0}
29
+ .head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:8px}
30
+ .head h1{font-size:18px;margin:0}
31
+ .head .sub{color:var(--muted);font-size:12.5px}
32
+ .head .meta{margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap}
33
+ .grid{display:grid;grid-template-columns:repeat(12,1fr);gap:10px}
34
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 12px;min-width:0;display:flex;flex-direction:column}
35
+ .panel h2{font-size:11.5px;margin:0 0 6px;color:var(--muted);font-weight:500;letter-spacing:.05em;text-transform:uppercase;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
36
+ .panel h2 b{color:var(--ink);font-weight:700;letter-spacing:0;text-transform:none;font-size:14px}
37
+ .panel h2 .r{margin-left:auto;font-size:11.5px;letter-spacing:0;text-transform:none}
38
+ .span12{grid-column:span 12}.span8{grid-column:span 8}.span7{grid-column:span 7}.span6{grid-column:span 6}.span5{grid-column:span 5}.span4{grid-column:span 4}
39
+ @media (max-width:1100px){.span8,.span7,.span5,.span4{grid-column:span 12}}
40
+ @media (max-width:760px){.span6{grid-column:span 12}}
41
+ .chip{display:inline-block;padding:0 7px;border-radius:999px;font-size:11.5px;font-weight:500;white-space:nowrap;line-height:1.5}
42
+ .c-live{background:var(--live-soft);color:var(--live)} .c-mock,.c-mixed{background:var(--mock-soft);color:var(--mock)} .c-planned,.c-next,.c-static{background:var(--plan-soft);color:var(--plan)} .c-partial{background:var(--live-soft);color:var(--live);outline:1px dashed var(--live)} .c-red,.c-missing,.c-fail,.c-behind{background:var(--red-soft);color:var(--red)} .c-navy{background:var(--soft);color:var(--navy)}
43
+ .g{font-family:var(--mono)} .dim{color:var(--muted)}
44
+ .light{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--plan);vertical-align:middle;margin-right:5px}
45
+ .light.ok{background:var(--live)} .light.fail,.light.behind{background:var(--red)} .light.stale,.light.partial{background:var(--mock)}
46
+ .new{position:relative} .new::after{content:'';position:absolute;top:-2px;right:-6px;width:6px;height:6px;border-radius:50%;background:var(--new)}
47
+ /* 상단 띠 */
48
+ .ticker{display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px;background:var(--navy);color:#fff;border-radius:10px;padding:6px 14px;margin-bottom:8px;font-size:12.5px}
49
+ .ticker .l{font-weight:700}
50
+ .ticker .s{display:flex;gap:12px;flex-wrap:wrap;margin-left:auto;font-size:12px;opacity:.95}
51
+ .ticker .s span{white-space:nowrap}
52
+ /* 여정 매트릭스 */
53
+ .jrow{display:grid;grid-template-columns:minmax(130px,.8fr) 74px 1.6fr 74px;gap:8px;align-items:center;padding:3px 0;border-top:1px solid var(--line);text-decoration:none;color:inherit}
54
+ .jrow:first-of-type{border-top:0}
55
+ .jrow:hover{background:var(--soft)}
56
+ .jrow .t{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
57
+ .jrow .a{font-size:11.5px;color:var(--muted);white-space:nowrap}
58
+ .dots{display:flex;gap:3px;flex-wrap:wrap}
59
+ .dot{display:inline-flex;align-items:center;gap:3px;font-size:10.5px;line-height:1.4;padding:0 5px;border:1px solid var(--line);border-radius:999px;white-space:nowrap;color:var(--muted)}
60
+ .dot .g{font-size:10px}
61
+ .dot.live{border-color:var(--live);color:var(--live)} .dot.mock{border-color:var(--mock);color:var(--mock)} .dot.planned{color:var(--plan)} .dot.next{border-style:dashed;color:var(--muted)}
62
+ .dot.warn{border-color:var(--red)}
63
+ .lane-h{font-size:10.5px;color:var(--muted);margin:4px 0 0}
64
+ /* 목록 */
65
+ .rows{display:flex;flex-direction:column}
66
+ .row{display:grid;gap:8px;align-items:center;padding:4px 0;border-top:1px solid var(--line);font-size:12.5px;text-decoration:none;color:inherit}
67
+ .row:first-child{border-top:0}
68
+ .row:hover{background:var(--soft)}
69
+ .row .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
70
+ .row .g.dim{font-size:11px}
71
+ .pbar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--plan-soft);min-width:60px}
72
+ .pbar i{display:block;height:100%}
73
+ .pbar .live{background:var(--live)}.pbar .mock{background:var(--mock)}.pbar .planned{background:var(--plan)}
74
+ .tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
75
+ .tile{background:var(--soft);border-radius:7px;padding:6px 8px}
76
+ .tile .v{font-size:17px;font-weight:700;font-family:var(--mono);font-variant-numeric:tabular-nums;line-height:1.1}
77
+ .tile .v small{font-size:11px;color:var(--muted);font-weight:400}
78
+ .tile .k{font-size:11px;color:var(--muted);margin-top:2px}
79
+ .bars{display:flex;flex-direction:column;gap:3px}
80
+ .bar{display:grid;grid-template-columns:56px 1fr 28px;gap:6px;align-items:center;font-size:11.5px}
81
+ .bar i{display:block;height:7px;border-radius:4px;background:var(--navy);opacity:.75}
82
+ .bar b{font-family:var(--mono);font-weight:500;text-align:right;color:var(--muted)}
83
+ .lights{display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;font-size:11.5px;margin-bottom:6px}
84
+ /* 표·상세 */
85
+ .tbl{overflow-x:auto}
86
+ table{border-collapse:collapse;width:100%;font-size:12.5px}
87
+ th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top;white-space:nowrap}
88
+ th{color:var(--muted);font-weight:500;font-size:11.5px}
89
+ td.mono,th.mono{font-family:var(--mono);font-size:12px}
90
+ td.wrap{white-space:normal;min-width:200px}
91
+ tr.link{cursor:pointer} tr.link:hover td{background:var(--soft)} tr.hl td{background:var(--live-soft)}
92
+ .kv{display:grid;grid-template-columns:auto 1fr;gap:5px 12px;font-size:12.5px;margin:0}
93
+ .kv dt{color:var(--muted);white-space:nowrap}.kv dd{margin:0;min-width:0}
94
+ .jcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
95
+ .jcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:8px;text-decoration:none;color:inherit}
96
+ .jcard:hover{border-color:var(--accent);box-shadow:0 0 0 2px var(--soft)}
97
+ .jcard .t{font-weight:700;font-size:15px;display:flex;justify-content:space-between;gap:8px;align-items:baseline}
98
+ .board{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px}
99
+ .scene{flex:0 0 240px;background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative;text-decoration:none;color:inherit}
100
+ .scene:hover,.scene.on{border-color:var(--accent);box-shadow:0 0 0 2px var(--soft)}
101
+ .scene .num{position:absolute;left:8px;top:8px;background:var(--navy);color:#fff;font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:999px;z-index:1}
102
+ .scene .shot{aspect-ratio:16/10;background:var(--soft);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;overflow:hidden;border-bottom:1px solid var(--line)}
103
+ .scene .shot img{width:100%;height:100%;object-fit:cover;object-position:top;display:block}
104
+ .scene .body{padding:10px 12px;display:flex;flex-direction:column;gap:4px}
105
+ .scene .l{font-weight:700;display:flex;justify-content:space-between;gap:6px;align-items:baseline}
106
+ .scene .intent{font-size:12px;color:var(--muted);min-height:2.6em}
107
+ .scene .warn{font-size:11px;color:var(--red)}
108
+ .scene.next{border-style:dashed}
109
+ .detail{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-top:8px}
110
+ .detail h3{margin:0 0 8px;font-size:16px;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
111
+ .detail .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
112
+ .detail h4{margin:10px 0 6px;font-size:11.5px;color:var(--muted);font-weight:500;letter-spacing:.04em;text-transform:uppercase}
113
+ .detail h4:first-child{margin-top:0}
114
+ .detail .big{width:100%;border:1px solid var(--line);border-radius:8px;display:block}
115
+ .node{display:flex;flex-direction:column;gap:2px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;font-size:12.5px;margin-bottom:6px;background:var(--bg)}
116
+ .node .n{font-family:var(--mono);font-size:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
117
+ .node .s{font-size:11.5px;color:var(--muted)}
118
+ .src{font-size:11px;color:var(--muted);font-family:var(--mono)}
119
+ .filters{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
120
+ .filters button,.tabs button{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:2px 10px;font-size:12px;cursor:pointer;font-family:inherit}
121
+ .filters button.on,.tabs button.on{background:var(--navy);color:#fff;border-color:var(--navy)}
122
+ .filters button b{font-family:var(--mono);font-weight:500;margin-left:4px;opacity:.8}
123
+ .tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
124
+ .log{list-style:none;margin:0;padding:0}
125
+ .log li{display:grid;grid-template-columns:52px 74px 1fr;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);font-size:12.5px;align-items:baseline}
126
+ .log .d{font-family:var(--mono);color:var(--muted);font-size:11.5px}
127
+ .log .s{min-width:0}.log .s .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
128
+ .log .tags{display:flex;gap:4px;flex-wrap:wrap;margin-top:2px}
129
+ .log .tags span{font-size:11px;padding:0 6px;border-radius:999px;background:var(--soft);color:var(--muted)}
130
+ .log .tags span.j{background:var(--live-soft);color:var(--live)}
131
+ .thumbs{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}
132
+ .thumb{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--card);text-decoration:none;color:inherit}
133
+ .thumb img{width:100%;aspect-ratio:16/10;object-fit:cover;object-position:top;display:block;border-bottom:1px solid var(--line)}
134
+ .thumb .c{padding:6px 8px;font-size:12px;display:flex;justify-content:space-between;gap:6px}
135
+ .prose{max-width:72ch;font-size:13.5px}.prose h3{font-size:15px;margin:18px 0 6px}.prose p{margin:6px 0}.prose ul{padding-left:18px;margin:6px 0}
136
+ .empty{color:var(--muted);font-size:12.5px}
137
+ ul.plain{margin:0;padding-left:16px;font-size:12.5px}ul.plain li{margin:3px 0}
138
+ button:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
139
+
140
+ /* CSP(style-src 'self')로 인라인 style 속성을 못 쓰므로 자잘한 배치도 클래스로 둔다 */
141
+ .fs11{font-size:11px}.fs115{font-size:11.5px}.fs12{font-size:12px}.fs125{font-size:12.5px}.normal{font-weight:400}
142
+ .mt6{margin-top:6px}.mt8{margin-top:8px}.mt12{margin-top:12px}.mb10{margin-bottom:10px}.mb12{margin:0 0 12px}
143
+ .wrapn{white-space:normal}.nolink{text-decoration:none}
144
+ .note{margin:0 0 8px;font-size:12.5px;color:var(--muted)}.warnnote{color:var(--red)}
145
+ .chip.off{opacity:.45}
146
+ .row.r-task{grid-template-columns:1fr 64px 46px 60px}.row.r-next{grid-template-columns:auto 1fr}.row.r-recent{grid-template-columns:38px 1fr}
147
+ .panel h2.mt8{margin-top:8px}
148
+
149
+ /* 로드맵 */
150
+ .row.r-road{grid-template-columns:auto 1fr auto auto}
151
+ .chip.c-planned.off{opacity:.6}
152
+ .fixmark{display:inline-block;padding:0 5px;border-radius:4px;font-size:10.5px;background:var(--mock-soft);color:var(--mock)}
153
+ .rm{display:flex;flex-direction:column;gap:10px;max-width:980px}
154
+ .rm-item{display:grid;grid-template-columns:36px 1fr;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
155
+ .rm-item.on{outline:2px solid var(--navy)}
156
+ .rm-item.is-done{opacity:.72}
157
+ .rm-num{font-size:18px;font-weight:700;color:var(--muted);text-align:center;padding-top:2px}
158
+ .rm-h{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
159
+ .rm-h h3{margin:0;font-size:16px;text-wrap:balance}
160
+ .rm-goal{margin:6px 0 8px;max-width:70ch}
161
+ .rm-kv{display:grid;grid-template-columns:76px 1fr;gap:6px 12px;margin:0;font-size:12.5px}
162
+ .rm-kv dt{color:var(--muted)}
163
+ .rm-kv dd{margin:0;min-width:0}
164
+ .rm-wait{color:var(--mock);font-weight:500}
165
+ .rm-scenes{display:flex;flex-wrap:wrap;gap:4px}
166
+ .rm-scenes .dot{text-decoration:none}
167
+ @media (max-width:640px){.rm-item{grid-template-columns:1fr}.rm-num{text-align:left}.rm-kv{grid-template-columns:1fr}}
package/site/map.js ADDED
@@ -0,0 +1,204 @@
1
+ (async function(){
2
+ const $ = (id) => document.getElementById(id);
3
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
4
+ const glyph = { live: '●', mock: '◐', mixed: '◐', partial: '◐', planned: '○', next: '○', static: '–', missing: '?' };
5
+ const word = { live: '동작', mock: '목업', mixed: '혼합', partial: '일부 동작', planned: '미착수', next: '다음 스펙', static: '정적', missing: '라우트 없음' };
6
+ const chip = (s) => `<span class="chip c-${esc(s)}"><span class="g">${glyph[s] || ''}</span> ${word[s] || esc(s)}</span>`;
7
+ const kst = (iso, withTime = true) => iso ? new Date(iso).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', ...(withTime ? { hour: '2-digit', minute: '2-digit', hour12: false } : {}) }) : '';
8
+ const mmdd = (iso) => { const d = new Date(iso); const p = new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit' }).formatToParts(d); return `${p.find((x) => x.type === 'month').value}.${p.find((x) => x.type === 'day').value}`; };
9
+ const md = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/`(.+?)`/g, '<code>$1</code>').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
10
+ const plain = (s) => String(s ?? '').replace(/\*\*/g, '').replace(/`/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
11
+ const BASE = '/map/';
12
+ const load = async (name) => { const r = await fetch(`${BASE}data/${name}.json`, { cache: 'no-store' }); if (!r.ok) throw new Error(`data/${name}.json ${r.status}`); return r.json(); };
13
+ let O = null, D = null;
14
+ const getO = async () => O || (O = await load('overview'));
15
+ const getD = async () => D || (D = await load('data'));
16
+
17
+ // 마지막 방문(브라우저 안에서만 기억)
18
+ let lastVisit = null; try { lastVisit = localStorage.getItem('map:lastVisit'); } catch {}
19
+ const isNew = (iso) => lastVisit && iso && iso > lastVisit;
20
+ const stampVisit = () => { try { localStorage.setItem('map:lastVisit', new Date().toISOString()); } catch {} };
21
+
22
+ const views = [['overview', '개요'], ['roadmap', '로드맵'], ['journeys', '여정'], ['tasks', '작업'], ['more', '더보기']];
23
+ $('nav').innerHTML = views.map(([id, label]) => `<a href="#/${id}" data-v="${id}">${esc(label)}</a>`).join('');
24
+ const setHead = (t, sub, meta) => { $('title').textContent = t; $('subtitle').textContent = sub || ''; $('meta').innerHTML = meta || ''; };
25
+ const statusChip = (st) => `<span class="chip ${st === '진행' ? 'c-live' : st === '대기' ? 'c-mock' : st === '폐기' ? 'c-red' : st === '완료' ? 'c-navy' : 'c-planned'}">${esc(st)}</span>`;
26
+ const rmClass = { 완료: 'c-navy', 진행: 'c-live', 다음: 'c-mock', 대기: 'c-planned', 이후: 'c-planned off' };
27
+ const rmChip = (st) => `<span class="chip ${rmClass[st] || 'c-red'}">${esc(st || '상태 없음')}</span>`;
28
+ const pbar = (counts, total) => `<div class="pbar">${['live', 'mock', 'planned'].map((k) => `<i class="${k}" data-w="${(counts[k] || 0) / total * 100}"></i>`).join('')}</div>`;
29
+
30
+ // ---------- 개요(첫 화면): overview.json만 읽는다. 식별자 없음 ----------
31
+ const overview = async () => {
32
+ const o = await getO();
33
+ $('brandName').textContent = `${o.project?.name || ''} 상황판`;
34
+ document.title = `${o.project?.name || ''} 상황판`;
35
+ $('brandSha').textContent = `${kst(o.headDate)} 기준`;
36
+ $('sideFoot').innerHTML = `생성 ${esc(kst(o.generatedAt))}<br>손으로 유지: 여정 파일 하나<br>나머지: 저장소 스캔`;
37
+ setHead('개요', '어디까지 됐나 · 지금 무엇을 하나 · 무엇이 바뀌었나');
38
+ const S = o.signals, C = o.counts;
39
+ const newCommits = o.recent.filter((c) => isNew(c.date)).length;
40
+ const lanes = [...new Set(o.journeys.map((j) => j.lane))];
41
+ const light = (k, label, text) => `<div><span class="light ${k}"></span>${esc(label)} <span class="dim">${esc(text)}</span></div>`;
42
+ const testText = S.tests === 'ok' ? `통과 ${S.lastRun.total}` : S.tests === 'fail' ? `실패 ${S.lastRun.failures}` : S.tests === 'stale' ? '이전 커밋 결과' : '리포트 없음';
43
+ return `
44
+ <div class="ticker"><span class="l">${esc(o.line)}</span><div class="s">${lastVisit ? `<span>마지막 방문 이후 여정 커밋 ${newCommits}</span>` : ''}<span><span class="light ${S.deploy}"></span>배포</span><span><span class="light ${S.tests}"></span>검사</span><span><span class="light ${S.adapters}"></span>스캔</span><span><span class="light ${S.warnings ? 'fail' : 'ok'}"></span>경고 ${S.warnings}</span></div></div>
45
+ <div class="grid">
46
+ <section class="panel span7" data-budget="matrix"><h2>여정 <b>어디까지 됐나</b> <span class="r dim">장면 ${C.stepsLive}/${C.stepsTotal} 동작 · 등급 A ${C.grades.A} B ${C.grades.B}</span></h2>
47
+ ${lanes.map((lane) => `<div class="lane-h">${esc(lane)}</div>${o.journeys.filter((j) => j.lane === lane).map((j) => `<a class="jrow" href="#/journeys/${esc(j.id)}"><span class="t">${esc(j.title)}</span><span class="a">${esc(j.actor)}</span><span class="dots">${j.steps.map((s) => `<span class="dot ${esc(s.status)} ${s.warn ? 'warn' : ''}" title="${esc(word[s.status])}${s.grade ? ' · 등급 ' + s.grade : ''}"><span class="g">${glyph[s.status]}</span>${esc(s.label)}</span>`).join('')}</span><span>${chip(j.status)}</span></a>`).join('')}`).join('')}
48
+ </section>
49
+ <section class="panel span5" data-budget="list"><h2>작업 <b>지금 무엇을 하나</b> <span class="r"><a href="#/tasks">전체</a></span></h2>
50
+ <div class="rows">${o.tasks.map((t) => `<a class="row r-task" href="#/tasks/${esc(t.id)}"><span class="t">${esc(t.title)}</span><span class="dim fs115">${esc(t.stage)}</span><span>${statusChip(t.status)}</span><span class="g dim">${t.pnDone + t.pnOpen ? `PN ${t.pnDone}/${t.pnDone + t.pnOpen}` : ''}${t.oq ? ` Q${t.oq}` : ''}</span></a>`).join('')}</div>
51
+ <h2 class="mt8">로드맵 <b>다음 차례</b> <span class="r"><a href="#/roadmap">완료 ${o.roadmapDone}/${o.roadmapTotal}</a></span></h2>
52
+ <div class="rows">${o.roadmap.map((m) => `<a class="row r-road" href="#/roadmap/${esc(m.id)}"><span>${rmChip(m.status)}</span><span class="t">${esc(m.title)}</span><span class="dim fs115">${esc(m.mode)}</span><span class="dim fs115">${m.waiting ? '결정 대기' : ''}</span></a>`).join('') || '<div class="row r-next"><span class="t dim">로드맵 없음</span></div>'}</div>
53
+ </section>
54
+ <section class="panel span4" data-budget="list"><h2>변화 <b>무엇이 바뀌었나</b> <span class="r"><a href="#/more/changes">14일 전체</a></span></h2>
55
+ <div class="bars">${o.areas.slice(0, 5).map(([a, n]) => `<div class="bar"><span>${esc(a)}</span><i data-w="${Math.round(n / o.areas[0][1] * 100)}"></i><b>${n}</b></div>`).join('')}</div>
56
+ <div class="rows mt8">${o.recent.slice(0, 3).map((c) => `<div class="row r-recent ${isNew(c.date) ? 'new' : ''}"><span class="g dim fs11">${esc(mmdd(c.date))}</span><span class="t" title="${esc(c.subject)}">${esc(c.subject)} <span class="dim">· ${esc(c.scenes[0] || '')}</span></span></div>`).join('')}</div>
57
+ </section>
58
+ <section class="panel span4" data-budget="list"><h2>상태 <b>신호</b></h2>
59
+ <div class="lights">${light(S.deploy, '배포', S.deploy === 'ok' ? '최신' : S.deploy === 'behind' ? `${S.deployBehind} 커밋 미배포` : '알 수 없음')}${light(S.tests, '검사', testText)}${light(S.adapters, '스캔', S.adapters === 'ok' ? '어댑터 전부 정상' : S.adapterNotes[0] || '')}${light(S.orphans ? 'partial' : 'ok', '미분류', `${S.orphans}건`)}${light(S.gated ? 'partial' : 'ok', '게이트 검사', S.gated ? `${S.gated}건 미실행` : '없음')}${light(C.oq ? 'partial' : 'ok', '열린 질문', `${C.oq}건`)}</div>
60
+ ${S.adapterNotes.length ? `<div class="dim fs115">${S.adapterNotes.map(esc).join('<br>')}</div>` : ''}
61
+ </section>
62
+ <section class="panel span4" data-budget="list"><h2>규모 <b>코드에서 센 숫자</b></h2>
63
+ <div class="tiles">${[[`${C.screensLive}<small>/${C.screens}</small>`, '실데이터 화면'], [C.apis, 'API'], [C.functions, 'DB 함수'], [C.tests, '검사'], [`${C.pnDone}<small>/${C.pnTotal}</small>`, '계획 항목'], [C.decisions, '확정 결정'], [C.proposed, '제안 결정'], [C.oq, '열린 질문']].map(([v, k]) => `<div class="tile"><div class="v">${v}</div><div class="k">${esc(k)}</div></div>`).join('')}</div>
64
+ </section>
65
+ </div>`;
66
+ };
67
+
68
+ // ---------- 로드맵: tasks/roadmap.md 순서대로. 장면 상태는 여정 파일, 작업 단계는 tasks 폴더에서 온다 ----------
69
+ const roadmapView = async (sel) => {
70
+ const d = await getD(); const R = d.roadmap || [];
71
+ const done = R.filter((m) => m.status === '완료').length;
72
+ setHead('로드맵', '목업을 걷어내고 실사용 여정으로 가는 순서. 위에서부터 차례로 한다', `<span>완료 ${done}/${R.length}</span>`);
73
+ if (!R.length) return '<p class="empty">로드맵 파일이 없거나 항목이 없습니다.</p>';
74
+ const T = d.tasks;
75
+ const scene = (s) => s.missing ? `<span class="chip c-red">장면 없음 ${esc(s.ref)}</span>` : `<a class="dot" href="#/journeys/${esc(s.journey)}/${esc(s.step)}" title="${esc(word[s.status] || s.status)}${s.fixed ? ' · 하드코딩 표시값' : ''}"><span class="g">${glyph[s.status] || ''}</span>${esc(s.label)}${s.fixed ? ' <span class="fixmark">고정값</span>' : ''}</a>`;
76
+ const task = (t) => t.missing ? `<span class="chip c-red">작업 폴더 없음 ${esc(t.name)}</span>` : `<a href="#/tasks/${esc(t.name)}">${esc(t.title)}</a> <span class="dim">${esc(t.stage)}${t.pnDone + t.pnOpen ? ` · PN ${t.pnDone}/${t.pnDone + t.pnOpen}` : ''}</span>`;
77
+ return `<div class="rm">${R.map((m) => `<article class="rm-item ${sel === m.id ? 'on' : ''} ${m.status === '완료' ? 'is-done' : ''}" id="rm-${esc(m.id)}">
78
+ <div class="rm-num g">${m.order}</div>
79
+ <div class="rm-body">
80
+ <div class="rm-h"><h3>${esc(m.title)}</h3>${rmChip(m.status)}<span class="chip c-navy">${esc(m.mode || '진행 방식 없음')}</span><span class="dim fs12">장면 동작 ${m.progress.live}/${m.progress.total}${m.progress.fixed ? ` · 하드코딩 표시 장면 ${m.progress.fixed}` : ''}</span></div>
81
+ ${m.goal ? `<p class="rm-goal">${md(m.goal)}</p>` : ''}
82
+ ${m.problems.length ? `<p class="note warnnote">${m.problems.map(esc).join('<br>')}</p>` : ''}
83
+ <dl class="rm-kv">
84
+ ${m.waitingOn ? `<dt>결정 대기</dt><dd class="rm-wait">${md(m.waitingOn)}</dd>` : ''}
85
+ ${m.done ? `<dt>완료 기준</dt><dd>${md(m.done)}</dd>` : ''}
86
+ ${m.deps.length ? `<dt>선행</dt><dd>${m.deps.map((x) => `<a href="#/roadmap/${esc(x.id)}">${esc(x.title)}</a> ${x.status ? rmChip(x.status) : ''}`).join(' ')}</dd>` : ''}
87
+ <dt>작업</dt><dd>${m.tasks.length ? m.tasks.map(task).join('<br>') : '<span class="dim">착수 전</span>'}</dd>
88
+ <dt>장면</dt><dd class="rm-scenes">${m.scenes.length ? m.scenes.map(scene).join('') : '<span class="dim">연결된 장면 없음</span>'}</dd>
89
+ </dl>
90
+ </div></article>`).join('')}</div>
91
+ <p class="src">정본 tasks/roadmap.md · 장면 상태는 map/semantic/journeys.json</p>`;
92
+ };
93
+
94
+ // ---------- 여정 ----------
95
+ const jcard = (j, A, T) => `<a class="jcard" href="#/journeys/${esc(j.id)}"><div class="t"><span>${esc(j.title)}</span>${chip(j.status)}</div><div class="dim fs12">${esc(A[j.actor] || j.actor)} · ${esc(j.goal)}</div>${pbar(j.counts, j.steps.length)}<div class="dots">${j.steps.map((s) => `<span class="dot ${esc(s.status)}"><span class="g">${glyph[s.status]}</span>${esc(s.label)}</span>`).join('')}</div>${j.taskNames.length ? `<div class="dim fs115">스펙: ${j.taskNames.map((n) => esc((T.find((t) => t.name === n) || { title: n }).title)).join(' · ')}</div>` : ''}</a>`;
96
+ const journeys = async (jid, sid) => {
97
+ const d = await getD(); const J = d.semantic.journeys, A = d.semantic.actors, T = d.tasks;
98
+ if (!jid) { setHead('여정', '배우가 목표를 이루는 흐름. 장면을 누르면 화면·API·DB·검사·결정·작업으로 내려간다'); return `<div class="jcards">${J.map((j) => jcard(j, A, T)).join('')}</div>`; }
99
+ const j = J.find((x) => x.id === jid); if (!j) return '<p class="empty">없는 여정입니다.</p>';
100
+ setHead(j.title, j.goal, `<span>${esc(A[j.actor] || j.actor)}</span>${chip(j.status)}`);
101
+ const step = sid ? j.steps.find((s) => s.id === sid) : null;
102
+ return `<p class="dim note"><a href="#/journeys">← 여정 목록</a></p>
103
+ <div class="board">${j.steps.map((s, i) => `<a class="scene ${esc(s.status)} ${step && step.id === s.id ? 'on' : ''}" href="#/journeys/${esc(j.id)}/${esc(s.id)}"><span class="num">${i + 1}</span>
104
+ <div class="shot">${s.captureFile ? `<img src="${BASE}captures/${esc(s.captureFile)}" alt="${esc(s.label)} 화면" loading="lazy">` : `<span>${s.status === 'next' ? '다음 스펙' : s.status === 'planned' ? '아직 화면 없음' : '캡처 없음'}</span>`}</div>
105
+ <div class="body"><div class="l"><span>${esc(s.label)}</span>${chip(s.status)}${s.grade ? `<span class="chip c-navy" title="D 주장 / C 관측 / B 검사 존재 / A 최신 커밋 통과">등급 ${s.grade}</span>` : ''}</div><div class="dim fs11">${esc(A[s.actor] || s.actor || '')}</div><div class="intent">${esc(s.intent || s.note || '')}</div>${s.warnings.length ? `<div class="warn">⚠ ${esc(s.warnings[0])}</div>` : ''}</div></a>`).join('')}</div>
106
+ ${step ? stepDetail(d, j, step) : '<p class="empty">장면을 고르면 상세가 여기 나옵니다.</p>'}`;
107
+ };
108
+ const testsText = (ts) => ts && ts.length ? esc(ts.join(', ')) : '<span class="dim">없음</span>';
109
+ const taskLink = (T, name) => { const t = T.find((x) => x.name === name); return t ? `<a href="#/tasks/${esc(name)}">${esc(t.title)}</a>` : esc(name); };
110
+ const stepDetail = (d, j, s) => {
111
+ const A = d.semantic.actors, T = d.tasks, i = j.steps.indexOf(s) + 1;
112
+ return `<div class="detail">
113
+ <h3><span class="g dim">${i}</span> ${esc(s.label)} ${chip(s.status)} ${s.grade ? `<span class="chip c-navy">등급 ${s.grade}</span>` : ''} <span class="dim fs125 normal">${esc(A[s.actor] || s.actor || '')}${s.intent ? ' · ' + esc(s.intent) : ''}</span></h3>
114
+ ${s.note ? `<p class="dim note">${esc(s.note)}</p>` : ''}
115
+ ${s.warnings.length ? `<p class="note warnnote">${s.warnings.map(esc).join('<br>')}</p>` : ''}
116
+ <div class="cols">
117
+ <div>${s.captureFile ? `<img class="big" src="${BASE}captures/${esc(s.captureFile)}" alt="${esc(s.label)} 확정 화면" loading="lazy">` : '<div class="empty">캡처 없음</div>'}<div class="src mt6">확인일 ${esc(s.reviewedAt || '—')} · 여정 파일 map/semantic/journeys.json</div></div>
118
+ <div>
119
+ <h4>화면</h4>${s.screenNodes.length ? s.screenNodes.map((n) => `<div class="node"><div class="n"><a href="#/more/screens/${encodeURIComponent(n.path)}">${esc(n.path)}</a> ${chip(n.source)}</div><div class="s">${esc((n.file || '').replace('web/src/', ''))}${n.mockVia && n.mockVia.length ? ' · 목업: ' + esc(n.mockVia.join(', ')) : ''}${n.fixedVia && n.fixedVia.length ? ' · 하드코딩 표시값: ' + esc(n.fixedVia.join(', ')) : ''}${n.last ? ' · ' + esc(n.last.date) + ' ' + esc(n.last.sha) : ''}</div><div class="s">검사: ${testsText(n.tests)}</div></div>`).join('') : '<div class="empty">연결된 화면 없음</div>'}
120
+ <h4>API → DB 함수 → 테이블</h4>${s.apiNodes.length ? s.apiNodes.map((a) => `<div class="node"><div class="n">${esc(a.method)} ${esc(a.path)}${a.missing ? ' <span class="chip c-red">BFF에 없음</span>' : ''}</div><div class="s">${a.calls.length ? esc(a.calls.join(', ')) : 'BFF 내부 처리'} · 검사: ${testsText(a.tests)}</div></div>`).join('') : '<div class="empty">호출 API 없음</div>'}
121
+ ${s.functionNodes.map((f) => `<div class="node"><div class="n">fn ${esc(f.name)}</div><div class="s">테이블: ${esc(f.tables.join(', ')) || '—'} · 검사: ${testsText(f.tests)}</div></div>`).join('')}
122
+ </div>
123
+ <div>
124
+ <h4>근거·계획</h4>${s.refNodes.length ? s.refNodes.map((r) => `<div class="node"><div class="n"><span class="chip c-navy">${esc(r.ref)}</span>${r.task ? taskLink(T, r.task) : r.wiki ? `<a href="#/more/decisions">${esc(r.wiki.title)}</a> <span class="dim">${esc(r.wiki.status)}</span>` : '<span class="dim">문서 미연결</span>'}</div>${r.file ? `<div class="src">${esc(r.file)}</div>` : r.wiki ? `<div class="src">${esc(r.wiki.file)}</div>` : ''}</div>`).join('') : '<div class="empty">참조 없음</div>'}
125
+ <h4>최근 이 장면에 닿은 커밋</h4>${s.recentCommits.length ? `<ul class="log">${s.recentCommits.map((c) => `<li><span class="d">${esc(c.sha)}</span><span class="d">${esc(kst(c.date))}</span><span class="s"><div class="t" title="${esc(c.subject)}">${esc(c.subject)}</div></span></li>`).join('')}</ul>` : '<div class="empty">14일 안에 없음</div>'}
126
+ </div>
127
+ </div></div>`;
128
+ };
129
+
130
+ // ---------- 작업 ----------
131
+ const tasksView = async (sel) => {
132
+ const d = await getD(); const T = d.tasks;
133
+ setHead('작업', '스펙 주도 작업의 단계와 상태. tasks/ 폴더에서 읽는다', `<span>진행 ${T.filter((t) => t.status === '진행').length} · 완료 ${T.filter((t) => t.status === '완료').length} · 폐기 ${T.filter((t) => t.status === '폐기').length}</span>`);
134
+ const t = sel ? T.find((x) => x.name === sel) : null;
135
+ const pipe = (x) => ['스펙 초안', '검토', '스펙 확정', '계획', '실행', '검증'].map((st) => { const on = st === '스펙 초안' ? x.spec.initial : st === '검토' ? x.spec.review : st === '스펙 확정' ? x.spec.final : st === '계획' ? !!x.plan : st === '실행' ? x.execution > 0 : x.verification.length > 0; return `<span class="chip ${on ? 'c-navy' : 'c-planned off'}">${st}</span>`; }).join(' ');
136
+ const detail = t ? `<div class="detail mb12"><h3>${esc(t.title)} ${statusChip(t.status)} <span class="dim g fs12">${esc(t.name)}</span></h3><div class="mb10">${pipe(t)}</div>
137
+ <div class="cols"><dl class="kv"><dt>단계</dt><dd>${esc(t.stage)}</dd><dt>결정</dt><dd class="g">DEC ${t.dec}</dd><dt>계획 항목</dt><dd class="g">PN ${t.pnDone}/${t.pnDone + t.pnOpen}</dd><dt>열린 질문</dt><dd class="g">${t.oq}</dd><dt>phase / 실행 기록</dt><dd class="g">${t.phases} / ${t.execution}</dd><dt>검증 문서</dt><dd class="g wrapn">${esc(t.verification.map((v) => v.split('/').pop()).join(', ')) || '—'}</dd><dt>문서 수</dt><dd class="g">${t.files}</dd><dt>위키 출처</dt><dd class="g">${t.wikiSources}</dd><dt>마지막 변경</dt><dd class="g">${t.last ? `${esc(t.last.date)} ${esc(t.last.sha)} ${esc(t.last.subject)}` : '—'}</dd></dl>
138
+ <div><h4>닿는 여정</h4>${t.journeys.length ? t.journeys.map((j) => `<div class="node"><div class="n"><a href="#/journeys/${esc(j.id)}">${esc(j.title)}</a> ${chip(j.status)}</div><div class="s">${esc(j.steps.join(' → '))}</div></div>`).join('') : '<div class="empty">여정 장면이 이 작업을 참조하지 않음</div>'}<div class="src mt8">${esc(t.plan || 'tasks/' + t.name)}</div></div></div></div>` : '';
139
+ return `${detail}<section class="panel"><div class="tbl"><table><thead><tr><th>날짜</th><th>작업</th><th>파이프라인</th><th>단계</th><th>상태</th><th>DEC</th><th>PN</th><th>OQ</th><th>14일</th></tr></thead><tbody>${T.map((x) => `<tr class="link ${sel === x.name ? 'hl' : ''}" data-href="#/tasks/${esc(x.name)}"><td class="mono dim">${esc(x.date)}</td><td class="wrap"><b>${esc(x.title)}</b></td><td>${pipe(x)}</td><td>${esc(x.stage)}</td><td>${statusChip(x.status)}</td><td class="mono">${x.dec || ''}</td><td class="mono">${x.pnDone + x.pnOpen ? `${x.pnDone}/${x.pnDone + x.pnOpen}` : ''}</td><td class="mono">${x.oq || ''}</td><td class="mono">${x.recentCommits}</td></tr>`).join('')}</tbody></table></div></section>`;
140
+ };
141
+
142
+ // ---------- 변화 ----------
143
+ let filter = null;
144
+ const changes = async () => {
145
+ const d = await getD();
146
+ setHead('변화', 'main 14일. 커밋이 어느 화면·여정에 닿았는지', `<span>${d.commits.length} 커밋</span>`);
147
+ const areas = Object.entries(d.areaCounts).sort((a, b) => b[1] - a[1]);
148
+ const items = d.commits.filter((c) => !filter || (filter === '여정' ? c.journeys.length : c.areas.includes(filter)));
149
+ return `<section class="panel"><div class="filters" id="filters"><button data-a="" class="${!filter ? 'on' : ''}">전체<b>${d.commits.length}</b></button><button data-a="여정" class="${filter === '여정' ? 'on' : ''}">여정에 닿음<b>${d.commits.filter((c) => c.journeys.length).length}</b></button>${areas.map(([a, n]) => `<button data-a="${esc(a)}" class="${filter === a ? 'on' : ''}">${esc(a)}<b>${n}</b></button>`).join('')}</div>
150
+ <ul class="log">${items.map((c) => `<li class="${isNew(c.date) ? 'new' : ''}"><span class="d">${esc(c.sha)}</span><span class="d">${esc(kst(c.date))}</span><span class="s"><div class="t" title="${esc(c.subject)}">${esc(c.subject)} <span class="dim">· ${c.files}파일</span></div><div class="tags">${c.areas.map((a) => `<span>${esc(a)}</span>`).join('')}${c.journeys.map((x) => `<span class="j"><a href="#/journeys/${esc(x.journey)}/${esc(x.step)}" class="nolink">${esc(x.label)}</a></span>`).join('')}</div></span></li>`).join('') || '<li class="empty">없음</li>'}</ul></section>`;
151
+ };
152
+
153
+ // ---------- 더보기: 결정·화면·API·DB·검사·이 상황판 ----------
154
+ const more = async (tab = 'decisions', sel) => {
155
+ const d = await getD();
156
+ const tabs = [['changes', '변화'], ['decisions', '결정'], ['screens', '화면'], ['backend', 'API·DB'], ['tests', '검사'], ['about', '이 상황판']];
157
+ setHead('더보기', '증거 층. 여정 장면에서 내려오면 여기에 닿는다');
158
+ const bar = `<div class="tabs">${tabs.map(([id, l]) => `<button data-t="${id}" class="${id === tab ? 'on' : ''}">${esc(l)}</button>`).join('')}</div>`;
159
+ if (tab === 'changes') return bar + await changes();
160
+ const body = tab === 'decisions' ? decisionsView(d) : tab === 'screens' ? screensView(d, sel) : tab === 'backend' ? backendView(d) : tab === 'tests' ? testsView(d) : aboutView(d);
161
+ return bar + body;
162
+ };
163
+ const decisionsView = (d) => `<section class="panel"><div class="tbl"><table><thead><tr><th>상태</th><th>결정</th><th>적용</th><th>장면 참조</th></tr></thead><tbody>${d.decisions.map((x) => `<tr><td>${chip(x.status === 'current' ? 'live' : x.status === 'proposed' ? 'mock' : 'planned')} <span class="dim g fs11">${esc(x.status)}</span></td><td class="wrap"><b>${esc(x.title)}</b><br><span class="src">${esc(x.file)}</span></td><td class="wrap dim">${esc(x.summary)}</td><td class="mono">${x.refs || '<span class="dim">0</span>'}</td></tr>`).join('')}</tbody></table></div></section>`;
164
+ const screensView = (d, sel) => {
165
+ const S = d.screens, J = d.semantic.journeys;
166
+ const selS = sel ? S.find((s) => s.path === sel) : null;
167
+ const withCap = J.flatMap((j) => j.steps).filter((s) => s.captureFile);
168
+ const capOf = (path) => withCap.find((s) => (s.screens || []).includes(path));
169
+ return `${selS ? `<div class="detail mb12"><h3><span class="g">${esc(selS.path)}</span> ${chip(selS.source)}</h3><div class="cols"><div>${capOf(selS.path) ? `<img class="big" src="${BASE}captures/${esc(capOf(selS.path).captureFile)}" alt="${esc(selS.path)} 화면">` : '<div class="empty">캡처 없음</div>'}</div><dl class="kv"><dt>페이지</dt><dd class="g">${esc(selS.file || '')}</dd><dt>닫힘 파일</dt><dd class="g wrapn">${esc((selS.files || []).map((f) => f.replace('web/src/', '')).join(', '))}</dd><dt>목업 출처</dt><dd class="g">${esc((selS.mockVia || []).join(', ')) || '—'}</dd><dt>API</dt><dd class="g">${esc(selS.apis.join(', ')) || '—'}</dd><dt>검사</dt><dd class="g">${testsText(selS.tests)}</dd><dt>여정</dt><dd>${selS.steps.map((x) => `<a href="#/journeys/${esc(x.journey)}/${esc(x.step)}">${esc(x.label)}</a>`).join('<br>') || '—'}</dd><dt>출처</dt><dd class="src">${esc(selS.src?.file || '')}:${esc(selS.src?.line ?? '')} ${esc(selS.src?.rule || '')}</dd></dl></div></div>` : ''}
170
+ <section class="panel"><h2>화면 <b>${d.summary.liveRoutes}/${S.length} 실데이터</b> <span class="dim">하드코딩 표시 ${d.summary.fixedRoutes}</span> <span class="r dim">여정 커버 ${d.coverage.screens.inJourney}/${d.coverage.screens.total}</span></h2><div class="tbl"><table><thead><tr><th class="mono">라우트</th><th>출처</th><th>표시값</th><th>페이지</th><th>API</th><th>여정 장면</th><th>검사</th><th>마지막 변경</th></tr></thead><tbody>${S.map((r) => `<tr class="link ${sel === r.path ? 'hl' : ''}" data-href="#/more/screens/${encodeURIComponent(r.path)}"><td class="mono">${esc(r.path)}${r.guarded ? ' <span class="dim" title="로그인 필요">🔒</span>' : ''}</td><td>${chip(r.source)}</td><td class="fs12">${r.fixedVia.length ? '<span class="fixmark">코드 고정</span>' : '<span class="dim">—</span>'}</td><td class="mono dim">${esc((r.file || '').replace('web/src/pages/', ''))}</td><td class="mono">${esc(r.apis.join(', '))}</td><td class="wrap fs12">${r.steps.map((x) => `<a href="#/journeys/${esc(x.journey)}/${esc(x.step)}">${esc(x.label)}</a>`).join('<br>') || '<span class="dim">여정에 없음</span>'}</td><td class="mono">${testsText(r.tests)}</td><td class="mono dim">${r.last ? `${esc(r.last.date)} ${esc(r.last.sha)}` : ''}</td></tr>`).join('')}</tbody></table></div></section>
171
+ <section class="panel mt12"><h2>확정 화면 <b>캡처 ${new Set(withCap.map((s) => s.captureFile)).size}장</b></h2><div class="thumbs">${[...new Map(withCap.map((s) => [s.captureFile, s])).values()].map((s) => `<a class="thumb" href="#/more/screens/${encodeURIComponent(s.screens[0])}"><img src="${BASE}captures/${esc(s.captureFile)}" alt="${esc(s.label)}" loading="lazy"><div class="c"><span>${esc(s.label)}</span><span class="g dim">${esc(s.screens[0])}</span></div></a>`).join('')}</div></section>`;
172
+ };
173
+ const backendView = (d) => `<div class="grid">
174
+ <section class="panel span6"><h2>API <b>${d.apis.length}</b></h2><div class="tbl"><table><thead><tr><th>메서드</th><th class="mono">경로</th><th class="mono">DB·Auth 호출</th><th>검사</th><th>출처</th></tr></thead><tbody>${d.apis.map((a) => `<tr><td class="mono">${esc(a.method)}</td><td class="mono">${esc(a.path)}</td><td class="mono">${esc(a.calls.join(', ')) || '<span class="dim">BFF 내부</span>'}</td><td class="mono">${testsText(a.tests)}</td><td class="src">${esc(a.src?.file || '')}:${esc(a.src?.line ?? '')}</td></tr>`).join('')}</tbody></table></div></section>
175
+ <section class="panel span6"><h2>DB 함수 <b>${d.functions.length}</b> <span class="r dim">BFF 미호출 ${d.orphans.functions.length}</span></h2><div class="tbl"><table><thead><tr><th class="mono">함수</th><th>BFF</th><th>테이블</th><th>검사</th></tr></thead><tbody>${d.functions.map((f) => `<tr><td class="mono">${esc(f.name)}</td><td>${f.usedByApi ? '<span class="chip c-live">호출</span>' : '<span class="chip c-planned">내부</span>'}</td><td class="wrap mono dim">${esc(f.tables.join(', ')) || '—'}</td><td class="mono">${testsText(f.tests)}</td></tr>`).join('')}</tbody></table></div></section>
176
+ <section class="panel span12"><h2>Migration <b>${d.migrations.length}</b></h2><div class="tbl"><table><thead><tr><th class="mono">파일</th><th>테이블</th><th>함수</th><th>grant</th><th>RLS</th><th>마지막 변경</th></tr></thead><tbody>${d.migrations.map((m) => `<tr><td class="mono">${esc(m.file)}</td><td class="wrap mono dim">${esc(m.tables.join(', '))}</td><td class="wrap mono dim">${esc(m.functions.join(', '))}</td><td class="mono">${m.grants}</td><td class="mono">${m.rls}</td><td class="mono dim">${m.last ? `${esc(m.last.date)} ${esc(m.last.sha)}` : ''}</td></tr>`).join('')}</tbody></table></div></section></div>`;
177
+ const testsView = (d) => `<section class="panel"><h2>검사 <b>${d.summary.tests}건</b> <span class="r dim">${d.testreport ? `마지막 실행 ${esc(kst(d.testreport.at))} · ${d.testreport.fresh ? '최신 커밋' : '이전 커밋'} · 실패 ${d.testreport.failures}` : '리포트 없음(npm run test:report)'}</span></h2><div class="tbl"><table><thead><tr><th>파일</th><th>종류</th><th>건수</th><th>게이트</th><th>마지막 실행</th></tr></thead><tbody>${d.tests.map((t) => `<tr><td class="mono">${esc(t.label)}</td><td>${esc(t.kind)}</td><td class="mono">${t.count}</td><td>${t.gated ? '<span class="chip c-planned">승인 필요</span>' : ''}</td><td>${t.lastRun ? `<span class="chip ${t.lastRun.passed ? 'c-live' : 'c-red'}">${t.lastRun.passed ? '통과' : '실패'}</span> ${t.lastRun.fresh ? '' : '<span class="dim">이전 커밋</span>'}` : '<span class="dim">—</span>'}</td></tr>`).join('')}</tbody></table></div>${d.orphans.tests.length ? `<p class="dim fs12">라우트·API·함수에 붙지 않는 검사: ${esc(d.orphans.tests.join(', '))}</p>` : ''}</section>`;
178
+ const aboutView = (d) => `<section class="panel prose">
179
+ <h3>무엇을 보는가</h3><p>코드가 아니라 프로젝트를 봅니다. 배우의 여정(무엇이 되는가), 스펙 주도 작업(어떻게 정하고 실행하는가), 결정(왜)이 앞에 있고 화면·API·DB는 그 주장을 뒷받침하는 증거로 뒤에 있습니다.</p>
180
+ <h3>두 층</h3><p><b>손으로 유지하는 층</b>은 여정 파일 하나입니다. 배우·목표·장면(intent, 상태, 화면, 캡처, 참조, 확인일)을 적습니다. 장면 상태가 바뀌는 병합은 같은 커밋에서 이 파일을 고칩니다.</p><p><b>생성하는 층</b>은 저장소 스캔입니다. 라우터·서버·migration·검사·tasks·위키·git·배포 매니페스트·검사 리포트를 어댑터가 읽습니다. 생성물은 커밋하지 않고 CI와 로컬 serve가 매번 만듭니다.</p>
181
+ <h3>신뢰</h3><ul><li>모든 노드에 출처(파일·줄·규칙)가 있고 상세에서 보입니다.</li><li>장면 등급: D 주장 / C 관측 / B 검사 존재 / A 최신 커밋에서 검사 통과.</li><li>어긋남(라우트 없음, 상태 모순, 참조 미해결)은 check가 막고, 빠짐(여정에 없는 화면, 안 불리는 API, 안 붙는 검사)은 미분류로 셉니다.</li><li>어댑터마다 단위 검사와 바닥값이 있어 스캐너가 깨지면 빈 표 대신 실패가 뜹니다.</li></ul>
182
+ <h3>단순함</h3><ul><li>첫 화면은 세 질문(어디까지·지금 무엇·무엇이 바뀜)만. 1440×900에서 스크롤 없음, 패널 8 이하, 목록 패널 6행 이하, 시스템 식별자 0. Playwright 예산 검사가 지킵니다.</li><li>깊이 3, 내비 5. 마지막 방문 이후 변화는 빨간 점으로.</li></ul>
183
+ <p class="src">어댑터 ${d.adapters.map((a) => `${a.name}:${a.status}`).join(' ')}</p></section>`;
184
+
185
+ // ---------- 라우터 ----------
186
+ const render = async () => {
187
+ const parts = location.hash.replace(/^#\/?/, '').split('/').map(decodeURIComponent);
188
+ const v = parts[0] || 'overview';
189
+ document.querySelectorAll('.nav a').forEach((a) => a.classList.toggle('on', a.dataset.v === v));
190
+ const map = { overview, roadmap: () => roadmapView(parts[1]), journeys: () => journeys(parts[1], parts[2]), tasks: () => tasksView(parts[1]), changes: () => more('changes'), more: () => more(parts[1] || 'decisions', parts[2]) };
191
+ try { $('view').innerHTML = await (map[v] || overview)(); }
192
+ catch (e) { $('view').innerHTML = `<p class="empty">불러오지 못했습니다: ${esc(e.message)}. 생성물(data/)이 없으면 npm run map 을 먼저 실행하세요.</p>`; }
193
+ $('view').querySelectorAll('[data-w]').forEach((el) => { el.style.width = `${el.dataset.w}%`; });
194
+ $('view').querySelectorAll('tr.link').forEach((tr) => tr.addEventListener('click', () => { location.hash = tr.dataset.href; }));
195
+ const f = $('filters'); if (f) f.addEventListener('click', (e) => { const b = e.target.closest('button'); if (!b) return; filter = b.dataset.a || null; render(); });
196
+ $('view').querySelectorAll('.tabs button').forEach((b) => b.addEventListener('click', () => { location.hash = `#/more/${b.dataset.t}`; }));
197
+ if (v === 'roadmap' && parts[1]) { const it = document.getElementById(`rm-${parts[1]}`); if (it) it.scrollIntoView({ block: 'start' }); } else if (v === 'journeys' && parts[2]) { const dd = $('view').querySelector('.detail'); if (dd) dd.scrollIntoView({ block: 'nearest' }); } else window.scrollTo(0, 0);
198
+ if (v !== 'overview') { const o = await getO(); $('brandName').textContent = `${o.project?.name || ''} 상황판`; $('brandSha').textContent = `${kst(o.headDate)} 기준`; }
199
+ };
200
+ window.addEventListener('hashchange', render);
201
+ await render();
202
+ window.addEventListener('pagehide', stampVisit);
203
+ setTimeout(stampVisit, 60000);
204
+ })();
@@ -0,0 +1,39 @@
1
+ // API 어댑터: BFF(app/server.mjs)의 라우트 목록 항목 route('METHOD', '/api/…', …) 또는 url.pathname 분기에서 api 노드를 만들고,
2
+ // 핸들러 블록의 rpc/·rpcCall('이름')·auth 호출을 function 노드로 잇는다.
3
+ export default function bff(g, fs, cfg) {
4
+ const c = cfg.bff;
5
+ if (!fs.has(c.server)) return `서버 파일 없음: ${c.server}`;
6
+ const server = fs.read(c.server);
7
+ const lines = server.split('\n');
8
+ const isRouteLine = (l) => /url\.pathname === '\/api\/[^']+'/.test(l) || /url\.pathname\.match\(/.test(l) || /^\s*route\('/.test(l) || /^\];/.test(l);
9
+ let n = 0;
10
+ const seenPaths = new Set();
11
+ lines.forEach((line, i) => {
12
+ const m = line.match(/^\s*route\('(\w+)', '(\/api\/[^']+)'/) || line.match(/req\.method === '(\w+)' && url\.pathname === '([^']+)'/) || line.match(/url\.pathname === '(\/api\/[^']+)'/);
13
+ if (!m) return;
14
+ const method = m.length === 3 ? m[1] : 'POST';
15
+ const path = m.length === 3 ? m[2] : m[1];
16
+ if (!path.startsWith('/api')) return;
17
+ let end = i + 1;
18
+ while (end < lines.length && !isRouteLine(lines[end])) end += 1;
19
+ const block = lines.slice(i, end).join('\n');
20
+ const rpc = [...new Set([...block.matchAll(/rpc\/(\w+)|rpcCall\('(\w+)'/g)].map((x) => x[1] || x[2]))];
21
+ const auth = [...new Set([...block.matchAll(/\/auth\/v1\/(\w+)/g)].map((x) => 'auth:' + x[1]))];
22
+ // 같은 경로가 메서드별로 따로 있으면(GET·POST /api/teams) id를 "METHOD 경로"로 나눈다.
23
+ const id = seenPaths.has(path) ? `${method} ${path}` : path;
24
+ seenPaths.add(path);
25
+ g.add('api', id, `${method} ${path}`, { method, calls: [...rpc, ...auth] }, { file: c.server, line: i + 1, rule: /route\('/.test(line) ? 'bff:route()' : 'bff:url.pathname ===' });
26
+ for (const f of rpc) { g.add('function', f, f); g.link('api', id, 'invokes', 'function', f); }
27
+ n += 1;
28
+ });
29
+ for (const d of c.dynamicRoutes || []) {
30
+ if (!server.includes(d.match)) continue;
31
+ const line = fs.lineOf(server, d.match);
32
+ for (const [method, path, calls] of d.routes) {
33
+ g.add('api', path, `${method} ${path}`, { method, calls }, { file: c.server, line, rule: 'bff:config.dynamicRoutes' });
34
+ for (const f of calls) { g.add('function', f, f); g.link('api', path, 'invokes', 'function', f); }
35
+ n += 1;
36
+ }
37
+ }
38
+ return n === 0 ? 'API 0건' : null;
39
+ }