sanjang 0.3.3 → 0.3.5
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.md +25 -13
- package/dashboard/app.js +1026 -147
- package/dashboard/index.html +23 -33
- package/dashboard/style.css +126 -295
- package/dist/bin/sanjang.js +48 -4
- package/dist/lib/config.js +3 -5
- package/dist/lib/engine/change-report.d.ts +27 -0
- package/dist/lib/engine/change-report.js +233 -0
- package/dist/lib/engine/diagnostics.js +2 -6
- package/dist/lib/engine/main-server.d.ts +15 -0
- package/dist/lib/engine/main-server.js +111 -0
- package/dist/lib/engine/naming.js +11 -2
- package/dist/lib/engine/pr.js +1 -1
- package/dist/lib/engine/process.js +4 -1
- package/dist/lib/engine/self-heal.js +16 -5
- package/dist/lib/engine/smart-init.js +7 -6
- package/dist/lib/engine/state.js +1 -1
- package/dist/lib/engine/suggest.js +1 -4
- package/dist/lib/engine/warp.d.ts +1 -1
- package/dist/lib/engine/warp.js +1 -1
- package/dist/lib/server.js +241 -49
- package/dist/lib/types.d.ts +19 -0
- package/package.json +2 -2
package/dashboard/app.js
CHANGED
|
@@ -12,6 +12,62 @@ const playgrounds = new Map();
|
|
|
12
12
|
/** @type {Map<string, Array<{text: string, source: string}>>} logs keyed by playground name */
|
|
13
13
|
const logs = new Map();
|
|
14
14
|
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Route inference — map file paths to preview routes
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Infer a preview route from a file path.
|
|
21
|
+
* Only works for file-based routing patterns (pages/, app/, views/).
|
|
22
|
+
* Returns null if no route can be inferred.
|
|
23
|
+
*/
|
|
24
|
+
function inferRouteFromPath(filePath) {
|
|
25
|
+
const lower = filePath.toLowerCase();
|
|
26
|
+
|
|
27
|
+
// Match pages/xxx.tsx, app/xxx/page.tsx, views/xxx.vue etc.
|
|
28
|
+
const patterns = [
|
|
29
|
+
// Next.js app router: app/dashboard/page.tsx → /dashboard
|
|
30
|
+
{ regex: /(?:^|[/\\])app[/\\](.+?)[/\\]page\.[^.]+$/, transform: m => '/' + m },
|
|
31
|
+
// Next.js app router: app/page.tsx → /
|
|
32
|
+
{ regex: /(?:^|[/\\])app[/\\]page\.[^.]+$/, transform: () => '/' },
|
|
33
|
+
// Next.js/Nuxt pages router: pages/login.tsx → /login
|
|
34
|
+
{ regex: /(?:^|[/\\])pages[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
|
|
35
|
+
// views/: views/Login.vue → /login
|
|
36
|
+
{ regex: /(?:^|[/\\])views[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const { regex, transform } of patterns) {
|
|
40
|
+
const match = regex.exec(lower);
|
|
41
|
+
if (match) {
|
|
42
|
+
let route = transform(match[1] || '');
|
|
43
|
+
// Clean up: remove index, trailing slash dupes
|
|
44
|
+
route = route.replace(/\/index$/, '/').replace(/\/+/g, '/');
|
|
45
|
+
// Remove dynamic route brackets for navigation: [id] → placeholder
|
|
46
|
+
route = route.replace(/\[([^\]]+)\]/g, '1');
|
|
47
|
+
return route || '/';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Navigate the preview iframe to a given route.
|
|
55
|
+
*/
|
|
56
|
+
function navigatePreview(route) {
|
|
57
|
+
const iframe = document.querySelector('#ws-preview iframe');
|
|
58
|
+
if (!iframe) return;
|
|
59
|
+
try {
|
|
60
|
+
const base = new URL(iframe.src);
|
|
61
|
+
base.pathname = route;
|
|
62
|
+
iframe.contentWindow.location.href = base.toString();
|
|
63
|
+
toast(`${route} 로 이동`, 'info');
|
|
64
|
+
} catch {
|
|
65
|
+
// cross-origin — reload with new path
|
|
66
|
+
const src = iframe.src.replace(/\/preview\/(\d+)\/.*/, `/preview/$1${route}`);
|
|
67
|
+
iframe.src = src;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
15
71
|
/** @type {Map<string, Array>} diagnostics keyed by playground name */
|
|
16
72
|
const diagnostics = new Map();
|
|
17
73
|
|
|
@@ -22,6 +78,11 @@ let currentWorkspace = null;
|
|
|
22
78
|
/** @type {number|null} polling interval for workspace changes */
|
|
23
79
|
let wsPollingInterval = null;
|
|
24
80
|
|
|
81
|
+
/** @type {object|null} 마지막 리포트 캐시 — 세이브 후 축소 표시용 */
|
|
82
|
+
let lastReport = null;
|
|
83
|
+
|
|
84
|
+
let compareMode = false;
|
|
85
|
+
|
|
25
86
|
const SHERPA_QUOTES = [
|
|
26
87
|
"요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
|
|
27
88
|
"'간단한 건데~' 그 말이 제일 무섭댜",
|
|
@@ -218,6 +279,7 @@ function handleWsMessage(msg) {
|
|
|
218
279
|
case 'playground-saved': {
|
|
219
280
|
if (!name) break;
|
|
220
281
|
toast(`💾 세이브됨: ${data?.message || ''}`, 'success');
|
|
282
|
+
if (currentWorkspace === name) transitionReportToSaved();
|
|
221
283
|
break;
|
|
222
284
|
}
|
|
223
285
|
|
|
@@ -225,6 +287,7 @@ function handleWsMessage(msg) {
|
|
|
225
287
|
if (!name) break;
|
|
226
288
|
toast('💾 오토세이브 완료', 'success');
|
|
227
289
|
if (currentWorkspace === name) {
|
|
290
|
+
transitionReportToSaved();
|
|
228
291
|
api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
|
|
229
292
|
}
|
|
230
293
|
break;
|
|
@@ -263,8 +326,8 @@ function handleWsMessage(msg) {
|
|
|
263
326
|
</div>`;
|
|
264
327
|
}).join('');
|
|
265
328
|
renderBlocks(data.files);
|
|
266
|
-
// Debounced AI
|
|
267
|
-
|
|
329
|
+
// Debounced AI report fetch
|
|
330
|
+
debounceReportFetch(name);
|
|
268
331
|
}
|
|
269
332
|
|
|
270
333
|
updateChangeSummary(data.count, data.ts);
|
|
@@ -272,6 +335,19 @@ function handleWsMessage(msg) {
|
|
|
272
335
|
debouncePreviewRefresh();
|
|
273
336
|
break;
|
|
274
337
|
}
|
|
338
|
+
|
|
339
|
+
case 'compare-ready': {
|
|
340
|
+
if (!compareMode) break;
|
|
341
|
+
const mainPreview = document.getElementById('ws-preview-main');
|
|
342
|
+
if (mainPreview && data?.port) {
|
|
343
|
+
const proxyUrl = `/preview/${data.port}/`;
|
|
344
|
+
mainPreview.innerHTML = `
|
|
345
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
346
|
+
<iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
|
|
347
|
+
toast('원본 프리뷰 준비 완료!', 'success');
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
275
351
|
}
|
|
276
352
|
}
|
|
277
353
|
|
|
@@ -1153,6 +1229,14 @@ function enterWorkspace(name) {
|
|
|
1153
1229
|
}
|
|
1154
1230
|
|
|
1155
1231
|
function exitWorkspace() {
|
|
1232
|
+
compareMode = false;
|
|
1233
|
+
const compareBtn = document.getElementById('ws-compare-btn');
|
|
1234
|
+
if (compareBtn) compareBtn.classList.remove('btn-active');
|
|
1235
|
+
const mainPreview = document.getElementById('ws-preview-main');
|
|
1236
|
+
if (mainPreview) mainPreview.classList.add('hidden');
|
|
1237
|
+
const container = document.getElementById('ws-preview-container');
|
|
1238
|
+
if (container) container.classList.remove('ws-split-view');
|
|
1239
|
+
lastReport = null;
|
|
1156
1240
|
currentWorkspace = null;
|
|
1157
1241
|
if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
|
|
1158
1242
|
document.getElementById('workspace').classList.add('hidden');
|
|
@@ -1164,6 +1248,108 @@ function exitWorkspace() {
|
|
|
1164
1248
|
}
|
|
1165
1249
|
window.exitWorkspace = exitWorkspace;
|
|
1166
1250
|
|
|
1251
|
+
async function fetchAndRenderReport(campName, withAi = false) {
|
|
1252
|
+
const section = document.getElementById('ws-report-section');
|
|
1253
|
+
if (!section) return;
|
|
1254
|
+
|
|
1255
|
+
try {
|
|
1256
|
+
const report = await api('GET', `/api/playgrounds/${campName}/change-report${withAi ? '?ai=true' : ''}`);
|
|
1257
|
+
|
|
1258
|
+
if (report.totalCount === 0) {
|
|
1259
|
+
if (lastReport && lastReport.summary) {
|
|
1260
|
+
section.style.display = '';
|
|
1261
|
+
section.classList.add('ws-report-saved');
|
|
1262
|
+
document.getElementById('ws-report-summary').innerHTML =
|
|
1263
|
+
`<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
|
|
1264
|
+
document.getElementById('ws-report-warnings').innerHTML = '';
|
|
1265
|
+
document.getElementById('ws-report-categories').innerHTML = '';
|
|
1266
|
+
} else {
|
|
1267
|
+
section.style.display = 'none';
|
|
1268
|
+
}
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
lastReport = report;
|
|
1273
|
+
section.style.display = '';
|
|
1274
|
+
section.classList.remove('ws-report-saved');
|
|
1275
|
+
|
|
1276
|
+
const summaryEl = document.getElementById('ws-report-summary');
|
|
1277
|
+
const changeSummaryText = document.getElementById('ws-changes-summary-text');
|
|
1278
|
+
if (report.humanDescription) {
|
|
1279
|
+
summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.humanDescription)}</div>`;
|
|
1280
|
+
} else if (report.summary) {
|
|
1281
|
+
summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.summary)}</div>`;
|
|
1282
|
+
} else {
|
|
1283
|
+
summaryEl.innerHTML = `<div class="ws-report-desc">${report.totalCount}개 파일 변경됨</div>`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (changeSummaryText && report.summary) {
|
|
1287
|
+
changeSummaryText.textContent = `⚠️ 저장 안 됨 — ${report.summary}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const warningsEl = document.getElementById('ws-report-warnings');
|
|
1291
|
+
if (report.warnings.length > 0) {
|
|
1292
|
+
warningsEl.innerHTML = report.warnings.map(w =>
|
|
1293
|
+
`<div class="ws-report-warning">
|
|
1294
|
+
<span class="ws-report-warning-icon">⚠️</span>
|
|
1295
|
+
<span>${escHtml(w.message)}</span>
|
|
1296
|
+
</div>`
|
|
1297
|
+
).join('');
|
|
1298
|
+
} else {
|
|
1299
|
+
warningsEl.innerHTML = '';
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
|
|
1303
|
+
const categoriesEl = document.getElementById('ws-report-categories');
|
|
1304
|
+
const details = report.categoryDetails || {};
|
|
1305
|
+
categoriesEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
|
|
1306
|
+
const items = details[cat];
|
|
1307
|
+
const hasDetails = items && items.length > 0;
|
|
1308
|
+
return `<div class="ws-report-cat-group">
|
|
1309
|
+
<div class="ws-report-cat-header">
|
|
1310
|
+
<span class="ws-report-cat-label">${categoryNames[cat] || cat}</span>
|
|
1311
|
+
<span class="ws-report-cat-count">${files.length}</span>
|
|
1312
|
+
</div>
|
|
1313
|
+
${hasDetails
|
|
1314
|
+
? `<ul class="ws-report-cat-items">${items.map((item, idx) => {
|
|
1315
|
+
const file = files[idx];
|
|
1316
|
+
const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
|
|
1317
|
+
return route
|
|
1318
|
+
? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></li>`
|
|
1319
|
+
: `<li>${escHtml(item)}</li>`;
|
|
1320
|
+
}).join('')}</ul>`
|
|
1321
|
+
: `<ul class="ws-report-cat-items">${files.map(f => {
|
|
1322
|
+
const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
|
|
1323
|
+
const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
|
|
1324
|
+
return route
|
|
1325
|
+
? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></li>`
|
|
1326
|
+
: `<li>${label}</li>`;
|
|
1327
|
+
}).join('')}</ul>`
|
|
1328
|
+
}
|
|
1329
|
+
</div>`;
|
|
1330
|
+
}).join('');
|
|
1331
|
+
|
|
1332
|
+
} catch {
|
|
1333
|
+
section.style.display = 'none';
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function transitionReportToSaved() {
|
|
1338
|
+
const section = document.getElementById('ws-report-section');
|
|
1339
|
+
if (!section || !lastReport) return;
|
|
1340
|
+
|
|
1341
|
+
if (lastReport.summary) {
|
|
1342
|
+
section.style.display = '';
|
|
1343
|
+
section.classList.add('ws-report-saved');
|
|
1344
|
+
document.getElementById('ws-report-summary').innerHTML =
|
|
1345
|
+
`<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
|
|
1346
|
+
document.getElementById('ws-report-warnings').innerHTML = '';
|
|
1347
|
+
document.getElementById('ws-report-categories').innerHTML = '';
|
|
1348
|
+
} else {
|
|
1349
|
+
section.style.display = 'none';
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1167
1353
|
function renderWorkspace(data) {
|
|
1168
1354
|
const { camp, changes, warpInstalled, previewUrl, autosave } = data;
|
|
1169
1355
|
|
|
@@ -1187,6 +1373,7 @@ function renderWorkspace(data) {
|
|
|
1187
1373
|
saveBtn.style.display = 'none';
|
|
1188
1374
|
changesEl.innerHTML = '';
|
|
1189
1375
|
renderBlocks([]);
|
|
1376
|
+
fetchAndRenderReport(camp.name);
|
|
1190
1377
|
} else {
|
|
1191
1378
|
unsavedSection.classList.remove('ws-no-changes');
|
|
1192
1379
|
summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${changes.count}개 파일 수정 중`;
|
|
@@ -1200,10 +1387,9 @@ function renderWorkspace(data) {
|
|
|
1200
1387
|
</div>`
|
|
1201
1388
|
).join('');
|
|
1202
1389
|
renderBlocks(changes.files);
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}).catch(() => {});
|
|
1390
|
+
// 먼저 fallback으로 빠르게 렌더, 이어서 AI로 업그레이드
|
|
1391
|
+
fetchAndRenderReport(camp.name);
|
|
1392
|
+
fetchAndRenderReport(camp.name, true);
|
|
1207
1393
|
}
|
|
1208
1394
|
|
|
1209
1395
|
// Actions — show commits as work history
|
|
@@ -1211,12 +1397,22 @@ function renderWorkspace(data) {
|
|
|
1211
1397
|
const commitList = data.commits || [];
|
|
1212
1398
|
if (commitList.length > 0) {
|
|
1213
1399
|
actionsEl.innerHTML = commitList.map(c =>
|
|
1214
|
-
`<
|
|
1215
|
-
<
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1400
|
+
`<details class="ws-commit-item" data-hash="${escHtml(c.hash)}">
|
|
1401
|
+
<summary class="ws-commit-summary">
|
|
1402
|
+
<span class="ws-commit-arrow">▶</span>
|
|
1403
|
+
<span class="ws-commit-msg">${escHtml(c.message)}</span>
|
|
1404
|
+
<span class="ws-commit-date">${escHtml(c.date)}</span>
|
|
1405
|
+
<button class="btn btn-ghost btn-sm ws-revert-btn" onclick="event.stopPropagation();event.preventDefault();revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
|
|
1406
|
+
</summary>
|
|
1407
|
+
<div class="ws-commit-report"></div>
|
|
1408
|
+
</details>`
|
|
1219
1409
|
).join('');
|
|
1410
|
+
// 펼칠 때 자동으로 리포트 로드
|
|
1411
|
+
actionsEl.querySelectorAll('.ws-commit-item').forEach(el => {
|
|
1412
|
+
el.addEventListener('toggle', function() {
|
|
1413
|
+
if (this.open) loadCommitReport(this, this.dataset.hash);
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1220
1416
|
} else if (changes.count > 0) {
|
|
1221
1417
|
actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 커밋 없음 (작업 중)</span>';
|
|
1222
1418
|
} else {
|
|
@@ -1241,9 +1437,22 @@ function renderWorkspace(data) {
|
|
|
1241
1437
|
previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
|
|
1242
1438
|
});
|
|
1243
1439
|
} else {
|
|
1244
|
-
previewEl.innerHTML =
|
|
1245
|
-
|
|
1246
|
-
|
|
1440
|
+
previewEl.innerHTML = `
|
|
1441
|
+
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;user-select:none;">
|
|
1442
|
+
<div style="width:4px;height:4px;image-rendering:pixelated;color:transparent;box-shadow:
|
|
1443
|
+
/* tent peak */
|
|
1444
|
+
12px 0 0 #6b7394,
|
|
1445
|
+
8px 4px 0 #6b7394, 12px 4px 0 #6b7394, 16px 4px 0 #6b7394,
|
|
1446
|
+
4px 8px 0 #6b7394, 8px 8px 0 #6b7394, 12px 8px 0 #6b7394, 16px 8px 0 #6b7394, 20px 8px 0 #6b7394,
|
|
1447
|
+
0px 12px 0 #4a5170, 4px 12px 0 #4a5170, 8px 12px 0 #4a5170, 12px 12px 0 #4a5170, 16px 12px 0 #4a5170, 20px 12px 0 #4a5170, 24px 12px 0 #4a5170,
|
|
1448
|
+
/* zzz */
|
|
1449
|
+
36px 0 0 #4a5170, 40px 4px 0 #4a5170, 36px 8px 0 #4a5170;
|
|
1450
|
+
transform:scale(2);margin-bottom:8px;
|
|
1451
|
+
"></div>
|
|
1452
|
+
<div style="color:var(--text-muted);font-size:14px;text-align:center;margin-top:24px;">
|
|
1453
|
+
캠프가 자고 있어유... zzZ
|
|
1454
|
+
</div>
|
|
1455
|
+
</div>`;
|
|
1247
1456
|
}
|
|
1248
1457
|
|
|
1249
1458
|
// Terminal button label
|
|
@@ -1413,15 +1622,57 @@ function updateQuestProgress(hasChanges, hasSaves) {
|
|
|
1413
1622
|
}
|
|
1414
1623
|
}
|
|
1415
1624
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
if (
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1625
|
+
async function loadCommitReport(el, hash) {
|
|
1626
|
+
const reportEl = el.querySelector('.ws-commit-report');
|
|
1627
|
+
if (!reportEl || reportEl.dataset.loaded) return;
|
|
1628
|
+
|
|
1629
|
+
reportEl.innerHTML = '<div class="ws-commit-report-loading">불러오는 중...</div>';
|
|
1630
|
+
|
|
1631
|
+
try {
|
|
1632
|
+
const report = await api('GET', `/api/playgrounds/${currentWorkspace}/commit-report/${hash}?ai=true`);
|
|
1633
|
+
const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
|
|
1634
|
+
const details = report.categoryDetails || {};
|
|
1635
|
+
|
|
1636
|
+
if (report.totalCount === 0) {
|
|
1637
|
+
reportEl.innerHTML = '<div class="ws-commit-report-empty">변경 내용 없음</div>';
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
reportEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
|
|
1642
|
+
const items = details[cat];
|
|
1643
|
+
const hasDetails = items && items.length > 0;
|
|
1644
|
+
return `<div class="ws-commit-cat">
|
|
1645
|
+
<span class="ws-commit-cat-label">${categoryNames[cat] || cat}</span>
|
|
1646
|
+
${hasDetails
|
|
1647
|
+
? items.map((item, idx) => {
|
|
1648
|
+
const file = files[idx];
|
|
1649
|
+
const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
|
|
1650
|
+
return route
|
|
1651
|
+
? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></div>`
|
|
1652
|
+
: `<div class="ws-commit-cat-item">${escHtml(item)}</div>`;
|
|
1653
|
+
}).join('')
|
|
1654
|
+
: files.map(f => {
|
|
1655
|
+
const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
|
|
1656
|
+
const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
|
|
1657
|
+
return route
|
|
1658
|
+
? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></div>`
|
|
1659
|
+
: `<div class="ws-commit-cat-item">${label}</div>`;
|
|
1660
|
+
}).join('')
|
|
1661
|
+
}
|
|
1662
|
+
</div>`;
|
|
1663
|
+
}).join('');
|
|
1664
|
+
reportEl.dataset.loaded = 'true';
|
|
1665
|
+
} catch {
|
|
1666
|
+
reportEl.innerHTML = '<div class="ws-commit-report-empty">불러오기 실패</div>';
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
let reportFetchTimer = null;
|
|
1671
|
+
function debounceReportFetch(campName) {
|
|
1672
|
+
if (reportFetchTimer) clearTimeout(reportFetchTimer);
|
|
1673
|
+
reportFetchTimer = setTimeout(() => {
|
|
1674
|
+
fetchAndRenderReport(campName, true);
|
|
1675
|
+
}, 5000);
|
|
1425
1676
|
}
|
|
1426
1677
|
|
|
1427
1678
|
let previewRefreshTimer = null;
|
|
@@ -1502,15 +1753,21 @@ function renderBrowserErrors() {
|
|
|
1502
1753
|
const panel = document.getElementById('ws-browser-errors');
|
|
1503
1754
|
if (!panel) return;
|
|
1504
1755
|
const badge = document.getElementById('ws-browser-error-badge');
|
|
1756
|
+
const fixBtn = document.getElementById('ws-fix-btn');
|
|
1757
|
+
// Show fix button if there are browser errors OR server error logs
|
|
1758
|
+
const serverHasErrors = currentWorkspace && (logs.get(currentWorkspace) ?? [])
|
|
1759
|
+
.some(l => l.source !== 'frontend' && l.source !== 'task' && /error|ERR|ENOENT|ECONNREFUSED|TypeError|SyntaxError|Cannot find/i.test(l.text));
|
|
1505
1760
|
if (browserErrors.length === 0) {
|
|
1506
1761
|
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">에러 없음</span>';
|
|
1507
1762
|
if (badge) badge.style.display = 'none';
|
|
1763
|
+
if (fixBtn) fixBtn.style.display = serverHasErrors ? '' : 'none';
|
|
1508
1764
|
return;
|
|
1509
1765
|
}
|
|
1510
1766
|
if (badge) {
|
|
1511
1767
|
badge.style.display = '';
|
|
1512
1768
|
badge.textContent = browserErrors.length;
|
|
1513
1769
|
}
|
|
1770
|
+
if (fixBtn) fixBtn.style.display = '';
|
|
1514
1771
|
panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
|
|
1515
1772
|
const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
|
|
1516
1773
|
return `<div class="ws-browser-error-item">
|
|
@@ -1543,13 +1800,99 @@ window.revertCommit = async function revertCommit(hash) {
|
|
|
1543
1800
|
}
|
|
1544
1801
|
};
|
|
1545
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* Build a structured prompt from browser errors + server logs
|
|
1805
|
+
* that Claude Code can use to diagnose and fix the issue.
|
|
1806
|
+
*/
|
|
1807
|
+
window.copyFixPrompt = async function copyFixPrompt() {
|
|
1808
|
+
const name = currentWorkspace;
|
|
1809
|
+
if (!name) return;
|
|
1810
|
+
|
|
1811
|
+
const sections = [];
|
|
1812
|
+
|
|
1813
|
+
// 1. Browser errors
|
|
1814
|
+
if (browserErrors.length > 0) {
|
|
1815
|
+
const errs = browserErrors.slice(-10).map(e => {
|
|
1816
|
+
let line = `[${e.level}] ${e.message}`;
|
|
1817
|
+
if (e.source) line += `\n 위치: ${e.source}${e.line ? ':' + e.line : ''}${e.col ? ':' + e.col : ''}`;
|
|
1818
|
+
return line;
|
|
1819
|
+
}).join('\n\n');
|
|
1820
|
+
sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// 2. Server logs (last 20 lines, stderr/error only)
|
|
1824
|
+
const serverLines = (logs.get(name) ?? [])
|
|
1825
|
+
.filter(l => l.source !== 'frontend' && l.source !== 'task')
|
|
1826
|
+
.slice(-20)
|
|
1827
|
+
.map(l => l.text.trim())
|
|
1828
|
+
.filter(Boolean);
|
|
1829
|
+
if (serverLines.length > 0) {
|
|
1830
|
+
sections.push(`## 서버 로그 (최근)\n\n${serverLines.join('\n')}`);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// 3. Current changes context
|
|
1834
|
+
try {
|
|
1835
|
+
const changes = await api('GET', `/api/playgrounds/${name}/changes`);
|
|
1836
|
+
if (changes.files?.length > 0) {
|
|
1837
|
+
const fileList = changes.files.map(f => `- ${f.status} ${f.path}`).join('\n');
|
|
1838
|
+
sections.push(`## 현재 수정된 파일\n\n${fileList}`);
|
|
1839
|
+
}
|
|
1840
|
+
} catch { /* ignore */ }
|
|
1841
|
+
|
|
1842
|
+
if (sections.length === 0) {
|
|
1843
|
+
toast('복사할 에러가 없습니다', 'info');
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const prompt = `아래 에러를 분석하고 수정해줘.
|
|
1848
|
+
|
|
1849
|
+
에러를 읽고 근본 원인을 먼저 파악한 다음, 최소한의 변경으로 고쳐줘.
|
|
1850
|
+
추측하지 말고 에러 메시지와 스택 트레이스를 근거로 진단해.
|
|
1851
|
+
수정 후 관련 파일만 변경하고, 변경 이유를 간단히 설명해줘.
|
|
1852
|
+
|
|
1853
|
+
${sections.join('\n\n---\n\n')}`;
|
|
1854
|
+
|
|
1855
|
+
try {
|
|
1856
|
+
await navigator.clipboard.writeText(prompt);
|
|
1857
|
+
toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
|
|
1858
|
+
} catch {
|
|
1859
|
+
// Fallback for non-HTTPS
|
|
1860
|
+
const ta = document.createElement('textarea');
|
|
1861
|
+
ta.value = prompt;
|
|
1862
|
+
ta.style.cssText = 'position:fixed;left:-9999px';
|
|
1863
|
+
document.body.appendChild(ta);
|
|
1864
|
+
ta.select();
|
|
1865
|
+
document.execCommand('copy');
|
|
1866
|
+
ta.remove();
|
|
1867
|
+
toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1546
1871
|
function clearBrowserErrors() {
|
|
1547
1872
|
browserErrors.length = 0;
|
|
1548
1873
|
renderBrowserErrors();
|
|
1549
1874
|
}
|
|
1550
1875
|
|
|
1551
|
-
window.wsShip = function() {
|
|
1876
|
+
window.wsShip = async function() {
|
|
1552
1877
|
if (!currentWorkspace) return;
|
|
1878
|
+
// Fetch report for ship confirmation
|
|
1879
|
+
try {
|
|
1880
|
+
const report = await api('GET', `/api/playgrounds/${currentWorkspace}/change-report?ai=true`);
|
|
1881
|
+
const reportPreview = document.getElementById('ship-report-preview');
|
|
1882
|
+
if (reportPreview && report.totalCount > 0) {
|
|
1883
|
+
let html = '';
|
|
1884
|
+
if (report.humanDescription) {
|
|
1885
|
+
html += `<div class="ship-report-desc">${escHtml(report.humanDescription)}</div>`;
|
|
1886
|
+
}
|
|
1887
|
+
if (report.warnings.length > 0) {
|
|
1888
|
+
html += report.warnings.map(w =>
|
|
1889
|
+
`<div class="ws-report-warning"><span>⚠️</span> ${escHtml(w.message)}</div>`
|
|
1890
|
+
).join('');
|
|
1891
|
+
}
|
|
1892
|
+
reportPreview.innerHTML = html;
|
|
1893
|
+
reportPreview.style.display = html ? '' : 'none';
|
|
1894
|
+
}
|
|
1895
|
+
} catch { /* non-blocking */ }
|
|
1553
1896
|
openShipModal(currentWorkspace);
|
|
1554
1897
|
};
|
|
1555
1898
|
|
|
@@ -1628,6 +1971,48 @@ window.togglePanel = function() {
|
|
|
1628
1971
|
document.getElementById('ws-panel')?.classList.toggle('open');
|
|
1629
1972
|
};
|
|
1630
1973
|
|
|
1974
|
+
window.toggleCompare = async function() {
|
|
1975
|
+
const container = document.getElementById('ws-preview-container');
|
|
1976
|
+
const mainPreview = document.getElementById('ws-preview-main');
|
|
1977
|
+
const compareBtn = document.getElementById('ws-compare-btn');
|
|
1978
|
+
if (!container || !mainPreview) return;
|
|
1979
|
+
|
|
1980
|
+
compareMode = !compareMode;
|
|
1981
|
+
|
|
1982
|
+
if (compareMode) {
|
|
1983
|
+
compareBtn.classList.add('btn-active');
|
|
1984
|
+
mainPreview.classList.remove('hidden');
|
|
1985
|
+
container.classList.add('ws-split-view');
|
|
1986
|
+
|
|
1987
|
+
toast('원본 서버를 준비하고 있어요...', 'info');
|
|
1988
|
+
try {
|
|
1989
|
+
const state = await api('POST', '/api/compare/start');
|
|
1990
|
+
if (state.status === 'running' && state.port) {
|
|
1991
|
+
const proxyUrl = `/preview/${state.port}/`;
|
|
1992
|
+
mainPreview.innerHTML = `
|
|
1993
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
1994
|
+
<iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
|
|
1995
|
+
} else if (state.status === 'starting') {
|
|
1996
|
+
mainPreview.innerHTML = `
|
|
1997
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
1998
|
+
<div class="ws-preview-loading">준비 중...</div>`;
|
|
1999
|
+
} else {
|
|
2000
|
+
mainPreview.innerHTML = `
|
|
2001
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
2002
|
+
<div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
|
|
2003
|
+
}
|
|
2004
|
+
} catch {
|
|
2005
|
+
mainPreview.innerHTML = `
|
|
2006
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
2007
|
+
<div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
|
|
2008
|
+
}
|
|
2009
|
+
} else {
|
|
2010
|
+
compareBtn.classList.remove('btn-active');
|
|
2011
|
+
mainPreview.classList.add('hidden');
|
|
2012
|
+
container.classList.remove('ws-split-view');
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
1631
2016
|
// ---------------------------------------------------------------------------
|
|
1632
2017
|
// Init
|
|
1633
2018
|
// ---------------------------------------------------------------------------
|
|
@@ -1737,150 +2122,643 @@ window.autoFix = async function autoFix(name) {
|
|
|
1737
2122
|
};
|
|
1738
2123
|
|
|
1739
2124
|
// ---------------------------------------------------------------------------
|
|
1740
|
-
//
|
|
2125
|
+
// Sherpa Guide Mode (replaces overlay onboarding)
|
|
1741
2126
|
// ---------------------------------------------------------------------------
|
|
1742
2127
|
|
|
1743
2128
|
const ONBOARDING_KEY = 'sanjang-onboarded';
|
|
1744
2129
|
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
},
|
|
1752
|
-
{
|
|
1753
|
-
target: '#ws-preview',
|
|
1754
|
-
title: '프리뷰 확인',
|
|
1755
|
-
text: '캠프에 들어가면 전체화면으로 프리뷰를 볼 수 있어요.',
|
|
1756
|
-
position: 'center',
|
|
1757
|
-
waitForWorkspace: true,
|
|
1758
|
-
},
|
|
1759
|
-
{
|
|
1760
|
-
target: '#ws-save-btn',
|
|
1761
|
-
title: '세이브하기',
|
|
1762
|
-
text: '변경사항이 있으면 세이브 버튼으로 저장해요. 게임 세이브처럼요!',
|
|
1763
|
-
position: 'left',
|
|
1764
|
-
waitForWorkspace: true,
|
|
1765
|
-
},
|
|
2130
|
+
const SHERPA_GUIDE = [
|
|
2131
|
+
"여기에 하고 싶은 거 적으면 되유. AI가 캠프 만들어줄겨.",
|
|
2132
|
+
"캠프 들어가면 프리뷰 전체화면으로 보여유. 편하쥬?",
|
|
2133
|
+
"세이브는 게임 세이브처럼 저장이여유. 💾 버튼 누르면 되유.",
|
|
2134
|
+
"팀에 보내기 누르면 PR 만들어주유. 셰르파가 다 해줄겨.",
|
|
2135
|
+
"그럼 이제 시작해봐유. 화이팅이여유~ 🏔️",
|
|
1766
2136
|
];
|
|
1767
2137
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2138
|
+
// ---------------------------------------------------------------------------
|
|
2139
|
+
// Sherpa Mode System (guide ↔ grumpy toggle)
|
|
2140
|
+
// ---------------------------------------------------------------------------
|
|
1771
2141
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
2142
|
+
let sherpaInterval = null;
|
|
2143
|
+
let sherpaMode = 'grumpy'; // 'guide' or 'grumpy'
|
|
2144
|
+
let sherpaQueue = [];
|
|
2145
|
+
let sherpaIdx = 0;
|
|
1775
2146
|
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2147
|
+
function shuffleArray(arr) {
|
|
2148
|
+
const a = [...arr];
|
|
2149
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
2150
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2151
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
2152
|
+
}
|
|
2153
|
+
return a;
|
|
2154
|
+
}
|
|
1780
2155
|
|
|
1781
|
-
|
|
2156
|
+
function setSherpaMode(mode) {
|
|
2157
|
+
sherpaMode = mode;
|
|
2158
|
+
sherpaIdx = 0;
|
|
2159
|
+
sherpaQueue = mode === 'guide' ? [...SHERPA_GUIDE] : shuffleArray(SHERPA_QUOTES);
|
|
1782
2160
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
show();
|
|
1787
|
-
return;
|
|
1788
|
-
}
|
|
2161
|
+
const el = document.getElementById('sherpa-quote');
|
|
2162
|
+
const speech = document.getElementById('sherpa-speech');
|
|
2163
|
+
if (!el || !speech) return;
|
|
1789
2164
|
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
const overlay = document.createElement('div');
|
|
1794
|
-
overlay.className = 'onboarding-overlay';
|
|
1795
|
-
|
|
1796
|
-
const rect = el.getBoundingClientRect();
|
|
1797
|
-
const highlight = document.createElement('div');
|
|
1798
|
-
highlight.className = 'onboarding-highlight';
|
|
1799
|
-
highlight.style.top = `${rect.top - 4}px`;
|
|
1800
|
-
highlight.style.left = `${rect.left - 4}px`;
|
|
1801
|
-
highlight.style.width = `${rect.width + 8}px`;
|
|
1802
|
-
highlight.style.height = `${rect.height + 8}px`;
|
|
1803
|
-
overlay.appendChild(highlight);
|
|
1804
|
-
|
|
1805
|
-
const tooltip = document.createElement('div');
|
|
1806
|
-
tooltip.className = 'onboarding-tooltip';
|
|
1807
|
-
tooltip.innerHTML = `
|
|
1808
|
-
<div class="onboarding-title">${s.title}</div>
|
|
1809
|
-
<div class="onboarding-text">${s.text}</div>
|
|
1810
|
-
<div class="onboarding-actions">
|
|
1811
|
-
<span class="onboarding-step">${step + 1}/${onboardingSteps.length}</span>
|
|
1812
|
-
<button class="btn btn-ghost btn-sm" onclick="skipOnboarding()">건너뛰기</button>
|
|
1813
|
-
<button class="btn btn-primary btn-sm" onclick="nextOnboardingStep()">${step === onboardingSteps.length - 1 ? '완료' : '다음'}</button>
|
|
1814
|
-
</div>`;
|
|
2165
|
+
// Visual mode indicator
|
|
2166
|
+
speech.classList.toggle('guide-mode', mode === 'guide');
|
|
1815
2167
|
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2168
|
+
// Fade transition to first message
|
|
2169
|
+
el.style.opacity = '0';
|
|
2170
|
+
setTimeout(() => {
|
|
2171
|
+
el.textContent = sherpaQueue[0];
|
|
2172
|
+
el.style.opacity = '1';
|
|
2173
|
+
}, 300);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
function advanceSherpa() {
|
|
2177
|
+
const el = document.getElementById('sherpa-quote');
|
|
2178
|
+
if (!el) return;
|
|
2179
|
+
|
|
2180
|
+
el.style.opacity = '0';
|
|
2181
|
+
setTimeout(() => {
|
|
2182
|
+
sherpaIdx++;
|
|
2183
|
+
if (sherpaIdx >= sherpaQueue.length) {
|
|
2184
|
+
if (sherpaMode === 'guide') {
|
|
2185
|
+
// Guide done → switch to grumpy
|
|
2186
|
+
localStorage.setItem(ONBOARDING_KEY, '1');
|
|
2187
|
+
setSherpaMode('grumpy');
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
// Reshuffle grumpy quotes
|
|
2191
|
+
sherpaQueue = shuffleArray(SHERPA_QUOTES);
|
|
2192
|
+
sherpaIdx = 0;
|
|
1827
2193
|
}
|
|
2194
|
+
el.textContent = sherpaQueue[sherpaIdx];
|
|
2195
|
+
el.style.opacity = '1';
|
|
2196
|
+
}, 500);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// ---------------------------------------------------------------------------
|
|
2200
|
+
// Basecamp Scene — Time-based Himalaya SVG
|
|
2201
|
+
// ---------------------------------------------------------------------------
|
|
2202
|
+
|
|
2203
|
+
const SCENE_THEMES = {
|
|
2204
|
+
dawn: {
|
|
2205
|
+
skyGradient: [['0%','#050810'],['60%','#0a1028'],['100%','#141830']],
|
|
2206
|
+
farRange: '#1a2040',
|
|
2207
|
+
midRange: '#141a30',
|
|
2208
|
+
ground: '#12151e',
|
|
2209
|
+
snowColor: 'rgba(200,215,240,0.35)',
|
|
2210
|
+
snowHighlight: 'rgba(220,230,250,0.4)',
|
|
2211
|
+
},
|
|
2212
|
+
morning: {
|
|
2213
|
+
skyGradient: [['0%','#1a2540'],['40%','#2d3a5c'],['70%','#5c4a6e'],['100%','#c4785a']],
|
|
2214
|
+
farRange: '#2a3058',
|
|
2215
|
+
midRange: '#1e2444',
|
|
2216
|
+
ground: '#12151e',
|
|
2217
|
+
snowColor: 'rgba(255,220,180,0.45)',
|
|
2218
|
+
snowHighlight: 'rgba(255,200,150,0.55)',
|
|
2219
|
+
},
|
|
2220
|
+
day: {
|
|
2221
|
+
skyGradient: [['0%','#1a3050'],['50%','#2a4a6a'],['100%','#3a5a7a']],
|
|
2222
|
+
farRange: '#253a58',
|
|
2223
|
+
midRange: '#1a2e48',
|
|
2224
|
+
ground: '#12151e',
|
|
2225
|
+
snowColor: 'rgba(255,255,255,0.5)',
|
|
2226
|
+
snowHighlight: 'rgba(255,255,255,0.6)',
|
|
2227
|
+
},
|
|
2228
|
+
evening: {
|
|
2229
|
+
skyGradient: [['0%','#060810'],['40%','#0e1530'],['75%','#1a1535'],['100%','#12151e']],
|
|
2230
|
+
farRange: '#182040',
|
|
2231
|
+
midRange: '#121830',
|
|
2232
|
+
ground: '#12151e',
|
|
2233
|
+
snowColor: 'rgba(200,180,230,0.35)',
|
|
2234
|
+
snowHighlight: 'rgba(220,200,240,0.4)',
|
|
2235
|
+
},
|
|
2236
|
+
};
|
|
1828
2237
|
|
|
1829
|
-
|
|
1830
|
-
|
|
2238
|
+
function renderBasecampScene() {
|
|
2239
|
+
const container = document.getElementById('bc-scene-container');
|
|
2240
|
+
if (!container) return;
|
|
2241
|
+
|
|
2242
|
+
const P = 4; // pixel size (4px grid)
|
|
2243
|
+
const hour = new Date().getHours();
|
|
2244
|
+
let period;
|
|
2245
|
+
if (hour >= 0 && hour < 6) period = 'dawn';
|
|
2246
|
+
else if (hour >= 6 && hour < 12) period = 'morning';
|
|
2247
|
+
else if (hour >= 12 && hour < 18) period = 'day';
|
|
2248
|
+
else period = 'evening';
|
|
2249
|
+
|
|
2250
|
+
const theme = SCENE_THEMES[period];
|
|
2251
|
+
|
|
2252
|
+
// Build gradient stops
|
|
2253
|
+
const stops = theme.skyGradient.map(([offset, color]) =>
|
|
2254
|
+
`<stop offset="${offset}" stop-color="${color}"/>`
|
|
2255
|
+
).join('');
|
|
2256
|
+
|
|
2257
|
+
// --- Stars ---
|
|
2258
|
+
let starsHtml = '';
|
|
2259
|
+
if (period === 'dawn') {
|
|
2260
|
+
const starPositions = [
|
|
2261
|
+
[45,18],[120,30],[200,12],[280,25],[360,8],[440,22],[520,15],[590,28],[150,40],[400,35],[60,42],[500,38],[330,10]
|
|
2262
|
+
];
|
|
2263
|
+
starsHtml = starPositions.map(([x,y]) =>
|
|
2264
|
+
`<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.4 + Math.random()*0.4}"><animate attributeName="opacity" values="${0.3};${0.8};${0.3}" dur="${1.5 + Math.random()*2}s" repeatCount="indefinite"/></rect>`
|
|
2265
|
+
).join('');
|
|
2266
|
+
// Crescent moon (pixel)
|
|
2267
|
+
starsHtml += `
|
|
2268
|
+
<rect x="576" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
|
|
2269
|
+
<rect x="580" y="16" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
|
|
2270
|
+
<rect x="580" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.25"/>
|
|
2271
|
+
<rect x="584" y="16" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
|
|
2272
|
+
<rect x="584" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.15"/>
|
|
2273
|
+
<rect x="588" y="20" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
|
|
2274
|
+
<rect x="580" y="24" width="${P}" height="${P}" fill="#c8cee6" opacity="0.25"/>
|
|
2275
|
+
<rect x="584" y="24" width="${P}" height="${P}" fill="#c8cee6" opacity="0.3"/>
|
|
2276
|
+
`;
|
|
2277
|
+
} else if (period === 'morning') {
|
|
2278
|
+
const starPositions = [[120,20],[400,15],[550,30]];
|
|
2279
|
+
starsHtml = starPositions.map(([x,y]) =>
|
|
2280
|
+
`<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="0.2"/>`
|
|
2281
|
+
).join('');
|
|
2282
|
+
} else if (period === 'day') {
|
|
2283
|
+
// Pixel clouds (4px grid)
|
|
2284
|
+
starsHtml = `
|
|
2285
|
+
<g opacity="0.12">
|
|
2286
|
+
<rect x="104" y="28" width="8" height="4" fill="#fff"/>
|
|
2287
|
+
<rect x="100" y="32" width="16" height="4" fill="#fff"/>
|
|
2288
|
+
<rect x="108" y="36" width="4" height="4" fill="#fff"/>
|
|
2289
|
+
<rect x="436" y="24" width="12" height="4" fill="#fff"/>
|
|
2290
|
+
<rect x="432" y="28" width="20" height="4" fill="#fff"/>
|
|
2291
|
+
<rect x="440" y="32" width="8" height="4" fill="#fff"/>
|
|
2292
|
+
</g>
|
|
2293
|
+
`;
|
|
2294
|
+
} else {
|
|
2295
|
+
const starPositions = [
|
|
2296
|
+
[80,15],[160,35],[250,10],[340,28],[430,18],[510,32],[590,12],[140,45],[380,40],[620,25]
|
|
2297
|
+
];
|
|
2298
|
+
starsHtml = starPositions.map(([x,y]) =>
|
|
2299
|
+
`<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.3 + Math.random()*0.5}"><animate attributeName="opacity" values="${0.2};${0.7};${0.2}" dur="${2 + Math.random()*2}s" repeatCount="indefinite"/></rect>`
|
|
2300
|
+
).join('');
|
|
1831
2301
|
}
|
|
1832
2302
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2303
|
+
// --- Mountains ---
|
|
2304
|
+
const farRangePoly = `0,220 0,150 20,150 20,140 40,140 40,125 55,125 55,115 70,115 70,105 85,105 85,95
|
|
2305
|
+
95,95 95,85 105,85 105,80 115,80 115,85 125,85 125,95 135,95 135,105
|
|
2306
|
+
150,105 150,115 165,115 165,125 180,125 180,135 200,135 200,145 220,145
|
|
2307
|
+
220,135 235,135 235,120 250,120 250,105 260,105 260,90 270,90 270,78 280,78
|
|
2308
|
+
280,70 288,70 288,62 295,62 295,56 302,56 302,50 308,50 308,45 314,45
|
|
2309
|
+
314,50 320,50 320,56 326,56 326,65 335,65 335,78 345,78 345,90
|
|
2310
|
+
355,90 355,105 370,105 370,120 385,120 385,135 400,135
|
|
2311
|
+
400,125 415,125 415,110 425,110 425,98 435,98 435,88 445,88 445,78
|
|
2312
|
+
450,78 450,70 456,70 456,64 462,64 462,58 466,58 466,54 470,54
|
|
2313
|
+
470,50 474,50 474,46 478,46 478,50 482,50 482,56 486,56
|
|
2314
|
+
486,64 492,64 492,72 498,72 498,82 508,82 508,95 518,95
|
|
2315
|
+
518,108 530,108 530,120 545,120 545,135 560,135
|
|
2316
|
+
560,125 570,125 570,112 580,112 580,100 590,100 590,88 598,88 598,78
|
|
2317
|
+
605,78 605,70 612,70 612,76 618,76 618,85 625,85 625,95
|
|
2318
|
+
635,95 635,108 645,108 645,120 658,120 658,135 680,135 680,220`;
|
|
2319
|
+
|
|
2320
|
+
const midRangePoly = `0,220 0,170 30,170 30,160 60,160 60,152 80,152 80,160 110,160 110,168
|
|
2321
|
+
140,168 140,158 160,158 160,148 175,148 175,140 188,140 188,135 198,135 198,140
|
|
2322
|
+
210,140 210,150 230,150 230,162 260,162 260,155 280,155 280,145 295,145 295,138
|
|
2323
|
+
310,138 310,145 330,145 330,155 350,155 350,165
|
|
2324
|
+
380,165 380,155 400,155 400,148 415,148 415,140 425,140 425,135 432,135 432,140
|
|
2325
|
+
440,140 440,150 460,150 460,160 480,160 480,168
|
|
2326
|
+
510,168 510,158 530,158 530,148 545,148 545,142 555,142 555,148
|
|
2327
|
+
565,148 565,158 585,158 585,165 610,165 610,158 630,158 630,165 660,165 660,170 680,170 680,220`;
|
|
2328
|
+
|
|
2329
|
+
// --- Snow caps (4px grid) ---
|
|
2330
|
+
const snowCaps = `
|
|
2331
|
+
<!-- Main peak snow -->
|
|
2332
|
+
<rect x="308" y="45" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
|
|
2333
|
+
<rect x="304" y="49" width="${P*4}" height="${P}" fill="${theme.snowColor}"/>
|
|
2334
|
+
<!-- Second peak snow -->
|
|
2335
|
+
<rect x="474" y="46" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
|
|
2336
|
+
<rect x="470" y="50" width="${P*4}" height="${P}" fill="${theme.snowColor}"/>
|
|
2337
|
+
<!-- Smaller peaks -->
|
|
2338
|
+
<rect x="104" y="80" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
|
|
2339
|
+
<rect x="604" y="70" width="${P*2}" height="${P}" fill="${theme.snowHighlight}"/>
|
|
2340
|
+
<!-- Mid-range snow -->
|
|
2341
|
+
<rect x="188" y="135" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
|
|
2342
|
+
<rect x="424" y="135" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
|
|
2343
|
+
<rect x="544" y="142" width="${P*2}" height="${P}" fill="${theme.snowColor}" opacity="0.5"/>
|
|
2344
|
+
`;
|
|
2345
|
+
|
|
2346
|
+
// --- Ground + texture ---
|
|
2347
|
+
const groundHtml = `
|
|
2348
|
+
<rect x="0" y="185" width="680" height="35" fill="${theme.ground}"/>
|
|
2349
|
+
<rect x="50" y="188" width="8" height="3" fill="#1a1d28" opacity="0.5"/>
|
|
2350
|
+
<rect x="200" y="190" width="6" height="2" fill="#1a1d28" opacity="0.4"/>
|
|
2351
|
+
<rect x="350" y="187" width="10" height="3" fill="#1a1d28" opacity="0.5"/>
|
|
2352
|
+
<rect x="500" y="191" width="7" height="2" fill="#1a1d28" opacity="0.4"/>
|
|
2353
|
+
<rect x="620" y="189" width="5" height="3" fill="#1a1d28" opacity="0.5"/>
|
|
2354
|
+
`;
|
|
2355
|
+
|
|
2356
|
+
// --- Shared basecamp elements (all 4px grid pixel art) ---
|
|
2357
|
+
|
|
2358
|
+
const tents = `
|
|
2359
|
+
<!-- Yellow expedition tent (pixel pyramid) -->
|
|
2360
|
+
<g>
|
|
2361
|
+
<rect x="88" y="172" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2362
|
+
<rect x="84" y="176" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2363
|
+
<rect x="88" y="176" width="${P}" height="${P}" fill="#a08818"/>
|
|
2364
|
+
<rect x="92" y="176" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2365
|
+
<rect x="80" y="180" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2366
|
+
<rect x="84" y="180" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2367
|
+
<rect x="88" y="180" width="${P}" height="${P}" fill="#2c2210"/>
|
|
2368
|
+
<rect x="92" y="180" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2369
|
+
<rect x="96" y="180" width="${P}" height="${P}" fill="#c8a820"/>
|
|
2370
|
+
</g>
|
|
2371
|
+
<!-- Blue dome tent (pixel) -->
|
|
2372
|
+
<g>
|
|
2373
|
+
<rect x="520" y="176" width="${P}" height="${P}" fill="#2855a0"/>
|
|
2374
|
+
<rect x="524" y="176" width="${P}" height="${P}" fill="#2855a0"/>
|
|
2375
|
+
<rect x="516" y="180" width="${P}" height="${P}" fill="#2855a0"/>
|
|
2376
|
+
<rect x="520" y="180" width="${P}" height="${P}" fill="#2855a0"/>
|
|
2377
|
+
<rect x="524" y="180" width="${P}" height="${P}" fill="#1a2040"/>
|
|
2378
|
+
<rect x="528" y="180" width="${P}" height="${P}" fill="#2855a0"/>
|
|
2379
|
+
<rect x="532" y="180" width="${P}" height="${P}" fill="#2855a0"/>
|
|
2380
|
+
</g>
|
|
2381
|
+
<!-- Green small tent (pixel) -->
|
|
2382
|
+
<g>
|
|
2383
|
+
<rect x="580" y="176" width="${P}" height="${P}" fill="#1e8040"/>
|
|
2384
|
+
<rect x="576" y="180" width="${P}" height="${P}" fill="#1e8040"/>
|
|
2385
|
+
<rect x="580" y="180" width="${P}" height="${P}" fill="#166030"/>
|
|
2386
|
+
<rect x="584" y="180" width="${P}" height="${P}" fill="#1e8040"/>
|
|
2387
|
+
</g>
|
|
2388
|
+
<!-- Red expedition tent (pixel) -->
|
|
2389
|
+
<g>
|
|
2390
|
+
<rect x="448" y="172" width="${P}" height="${P}" fill="#b83030"/>
|
|
2391
|
+
<rect x="444" y="176" width="${P}" height="${P}" fill="#b83030"/>
|
|
2392
|
+
<rect x="448" y="176" width="${P}" height="${P}" fill="#902020"/>
|
|
2393
|
+
<rect x="452" y="176" width="${P}" height="${P}" fill="#b83030"/>
|
|
2394
|
+
<rect x="440" y="180" width="${P}" height="${P}" fill="#b83030"/>
|
|
2395
|
+
<rect x="444" y="180" width="${P}" height="${P}" fill="#b83030"/>
|
|
2396
|
+
<rect x="448" y="180" width="${P}" height="${P}" fill="#401010"/>
|
|
2397
|
+
<rect x="452" y="180" width="${P}" height="${P}" fill="#b83030"/>
|
|
2398
|
+
<rect x="456" y="180" width="${P}" height="${P}" fill="#b83030"/>
|
|
2399
|
+
</g>
|
|
2400
|
+
`;
|
|
2401
|
+
|
|
2402
|
+
const flagColors = ['#e74c3c','#f39c12','#fff','#2ecc71','#3498db'];
|
|
2403
|
+
const prayerFlags1 = flagColors.map((c, i) =>
|
|
2404
|
+
`<rect x="${156 + i*8}" y="172" width="${P}" height="${P}" fill="${c}" opacity="0.7"/>`
|
|
2405
|
+
).join('') + flagColors.map((c, i) =>
|
|
2406
|
+
`<rect x="${156 + i*8}" y="168" width="${P}" height="1" fill="#4a5170" opacity="0.5"/>`
|
|
2407
|
+
).join('');
|
|
2408
|
+
|
|
2409
|
+
const prayerFlags2 = flagColors.map((c, i) =>
|
|
2410
|
+
`<rect x="${420 + i*8}" y="168" width="${P}" height="${P}" fill="${c}" opacity="0.6"/>`
|
|
2411
|
+
).join('') + flagColors.map((c, i) =>
|
|
2412
|
+
`<rect x="${420 + i*8}" y="164" width="${P}" height="1" fill="#4a5170" opacity="0.4"/>`
|
|
2413
|
+
).join('');
|
|
2414
|
+
|
|
2415
|
+
const supplies = `
|
|
2416
|
+
<!-- Supply crates (pixel) -->
|
|
2417
|
+
<rect x="128" y="180" width="${P*2}" height="${P}" fill="#6b4a28"/>
|
|
2418
|
+
<rect x="128" y="180" width="${P*2}" height="1" fill="#8b6a38"/>
|
|
2419
|
+
<rect x="128" y="176" width="${P*2}" height="${P}" fill="#5a3a20"/>
|
|
2420
|
+
<rect x="128" y="176" width="${P*2}" height="1" fill="#7a5a30"/>
|
|
2421
|
+
<rect x="136" y="180" width="${P}" height="${P}" fill="#5a3a20"/>
|
|
2422
|
+
<!-- Oxygen tanks (pixel) -->
|
|
2423
|
+
<rect x="472" y="180" width="${P}" height="${P}" fill="#4a6a8a"/>
|
|
2424
|
+
<rect x="472" y="176" width="${P}" height="${P}" fill="#6a8aaa"/>
|
|
2425
|
+
<rect x="476" y="180" width="${P}" height="${P}" fill="#4a6a8a"/>
|
|
2426
|
+
<rect x="476" y="176" width="${P}" height="${P}" fill="#6a8aaa"/>
|
|
2427
|
+
<!-- Signpost (pixel) -->
|
|
2428
|
+
<rect x="300" y="172" width="${P}" height="${P*3}" fill="#5a4a30"/>
|
|
2429
|
+
<rect x="296" y="172" width="${P*3}" height="${P}" fill="#6b5a38"/>
|
|
2430
|
+
<rect x="308" y="173" width="${P}" height="2" fill="#6b5a38"/>
|
|
2431
|
+
<!-- Rope coil (pixel) -->
|
|
2432
|
+
<rect x="600" y="176" width="${P}" height="${P}" fill="#8b7a50"/>
|
|
2433
|
+
<rect x="604" y="176" width="${P}" height="${P}" fill="#8b7a50"/>
|
|
2434
|
+
<rect x="596" y="180" width="${P}" height="${P}" fill="#8b7a50"/>
|
|
2435
|
+
<rect x="608" y="180" width="${P}" height="${P}" fill="#8b7a50"/>
|
|
2436
|
+
<rect x="600" y="184" width="${P}" height="${P}" fill="#8b7a50"/>
|
|
2437
|
+
<rect x="604" y="184" width="${P}" height="${P}" fill="#8b7a50"/>
|
|
2438
|
+
<!-- Ice axe (pixel) -->
|
|
2439
|
+
<rect x="144" y="168" width="${P}" height="${P}" fill="#8090b0"/>
|
|
2440
|
+
<rect x="144" y="172" width="${P}" height="${P}" fill="#6b5a38"/>
|
|
2441
|
+
<rect x="144" y="176" width="${P}" height="${P}" fill="#6b5a38"/>
|
|
2442
|
+
<rect x="140" y="168" width="${P}" height="${P}" fill="#aab0c0"/>
|
|
2443
|
+
`;
|
|
2444
|
+
|
|
2445
|
+
// --- Stone ring around campfire (4px grid) ---
|
|
2446
|
+
const stoneRing = `
|
|
2447
|
+
<rect x="320" y="184" width="${P}" height="${P}" fill="#3a3a40"/>
|
|
2448
|
+
<rect x="324" y="184" width="${P}" height="${P}" fill="#454550"/>
|
|
2449
|
+
<rect x="336" y="184" width="${P}" height="${P}" fill="#454550"/>
|
|
2450
|
+
<rect x="340" y="184" width="${P}" height="${P}" fill="#3a3a40"/>
|
|
2451
|
+
<rect x="318" y="180" width="${P}" height="${P}" fill="#454550"/>
|
|
2452
|
+
<rect x="342" y="180" width="${P}" height="${P}" fill="#3a3a40"/>
|
|
2453
|
+
`;
|
|
2454
|
+
|
|
2455
|
+
// --- Campfire (period-specific) ---
|
|
2456
|
+
let campfireHtml = '';
|
|
2457
|
+
if (period === 'dawn') {
|
|
2458
|
+
// Dim embers (pixel)
|
|
2459
|
+
campfireHtml = `
|
|
2460
|
+
${stoneRing}
|
|
2461
|
+
<rect x="328" y="180" width="${P}" height="${P}" fill="#8b2200" opacity="0.5"/>
|
|
2462
|
+
<rect x="332" y="180" width="${P}" height="${P}" fill="#a03000" opacity="0.4"/>
|
|
2463
|
+
`;
|
|
2464
|
+
} else if (period === 'morning') {
|
|
2465
|
+
// Smoke only (pixel)
|
|
2466
|
+
campfireHtml = `
|
|
2467
|
+
${stoneRing}
|
|
2468
|
+
<rect x="328" y="180" width="${P}" height="${P}" fill="#5a4a30"/>
|
|
2469
|
+
<rect x="332" y="180" width="${P}" height="${P}" fill="#5a4a30"/>
|
|
2470
|
+
<rect x="328" y="176" width="${P}" height="${P}" fill="#4a5170" opacity="0.2"/>
|
|
2471
|
+
<rect x="332" y="172" width="${P}" height="${P}" fill="#4a5170" opacity="0.15"/>
|
|
2472
|
+
<rect x="328" y="168" width="${P}" height="${P}" fill="#4a5170" opacity="0.1"/>
|
|
2473
|
+
`;
|
|
2474
|
+
} else if (period === 'day') {
|
|
2475
|
+
// No fire, just logs (pixel)
|
|
2476
|
+
campfireHtml = `
|
|
2477
|
+
${stoneRing}
|
|
2478
|
+
<rect x="324" y="180" width="${P*3}" height="${P}" fill="#5a4a30"/>
|
|
2479
|
+
<rect x="328" y="176" width="${P*2}" height="${P}" fill="#6b4a28"/>
|
|
2480
|
+
`;
|
|
2481
|
+
} else {
|
|
2482
|
+
// Full fire with glow (all 4px pixel)
|
|
2483
|
+
campfireHtml = `
|
|
2484
|
+
${stoneRing}
|
|
2485
|
+
<!-- Glow (rect-based) -->
|
|
2486
|
+
<rect x="316" y="168" width="${P*7}" height="${P*4}" fill="#ff8c32" opacity="0.04"/>
|
|
2487
|
+
<rect x="320" y="172" width="${P*5}" height="${P*3}" fill="#ff6600" opacity="0.06"/>
|
|
2488
|
+
<!-- Logs -->
|
|
2489
|
+
<rect x="324" y="180" width="${P*3}" height="${P}" fill="#5a3a20"/>
|
|
2490
|
+
<rect x="326" y="180" width="${P*3}" height="${P}" fill="#6b4a28"/>
|
|
2491
|
+
<!-- Flames (pixel, animated with steps) -->
|
|
2492
|
+
<rect x="328" y="176" width="${P}" height="${P}" fill="#ff6600">
|
|
2493
|
+
<animate attributeName="opacity" values="1;0.6;1" dur="0.4s" steps="2" repeatCount="indefinite"/>
|
|
2494
|
+
</rect>
|
|
2495
|
+
<rect x="332" y="172" width="${P}" height="${P}" fill="#ffcc00">
|
|
2496
|
+
<animate attributeName="opacity" values="0.8;1;0.6" dur="0.5s" steps="2" repeatCount="indefinite"/>
|
|
2497
|
+
</rect>
|
|
2498
|
+
<rect x="332" y="176" width="${P}" height="${P}" fill="#ff8800"/>
|
|
2499
|
+
<rect x="328" y="172" width="${P}" height="${P}" fill="#ff9900" opacity="0.7">
|
|
2500
|
+
<animate attributeName="opacity" values="0.7;0.3;0.7" dur="0.35s" steps="2" repeatCount="indefinite"/>
|
|
2501
|
+
</rect>
|
|
2502
|
+
<rect x="336" y="176" width="${P}" height="${P}" fill="#ff6600" opacity="0.6"/>
|
|
2503
|
+
<rect x="330" y="168" width="${P}" height="${P}" fill="#ff6600" opacity="0.4">
|
|
2504
|
+
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="0.6s" steps="2" repeatCount="indefinite"/>
|
|
2505
|
+
</rect>
|
|
2506
|
+
<!-- Smoke (pixel) -->
|
|
2507
|
+
<rect x="332" y="164" width="${P}" height="${P}" fill="#4a5170" opacity="0.15">
|
|
2508
|
+
<animate attributeName="opacity" values="0.15;0.05;0.15" dur="2s" repeatCount="indefinite"/>
|
|
2509
|
+
</rect>
|
|
2510
|
+
<rect x="328" y="160" width="${P}" height="${P}" fill="#4a5170" opacity="0.08"/>
|
|
2511
|
+
`;
|
|
2512
|
+
}
|
|
1837
2513
|
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
2514
|
+
// --- Sherpa (period-specific) ---
|
|
2515
|
+
let sherpaHtml = '';
|
|
2516
|
+
if (period === 'dawn') {
|
|
2517
|
+
// Sleeping horizontally
|
|
2518
|
+
sherpaHtml = `
|
|
2519
|
+
<g transform="translate(345, 178)">
|
|
2520
|
+
<!-- Body lying flat -->
|
|
2521
|
+
<rect x="0" y="0" width="4" height="4" fill="#3498db"/>
|
|
2522
|
+
<rect x="4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2523
|
+
<rect x="8" y="0" width="4" height="4" fill="#3498db"/>
|
|
2524
|
+
<rect x="12" y="0" width="4" height="4" fill="#2c3e50"/>
|
|
2525
|
+
<rect x="16" y="0" width="4" height="4" fill="#2c3e50"/>
|
|
2526
|
+
<!-- Head -->
|
|
2527
|
+
<rect x="-4" y="-1" width="4" height="4" fill="#f5c6a0"/>
|
|
2528
|
+
<!-- Hat flat -->
|
|
2529
|
+
<rect x="-8" y="-2" width="4" height="4" fill="#e74c3c"/>
|
|
2530
|
+
<!-- Zzz (pixel) -->
|
|
2531
|
+
<rect x="8" y="-8" width="${P}" height="${P}" fill="#8888aa" opacity="0.5"/>
|
|
2532
|
+
<rect x="12" y="-12" width="${P}" height="${P}" fill="#8888aa" opacity="0.4"/>
|
|
2533
|
+
<rect x="14" y="-16" width="${P}" height="${P}" fill="#8888aa" opacity="0.3"/>
|
|
2534
|
+
<rect x="16" y="-20" width="${P}" height="${P}" fill="#8888aa" opacity="0.2"/>
|
|
2535
|
+
</g>
|
|
2536
|
+
`;
|
|
2537
|
+
} else if (period === 'morning') {
|
|
2538
|
+
// Stretching (arms raised)
|
|
2539
|
+
sherpaHtml = `
|
|
2540
|
+
<g transform="translate(345, 170)">
|
|
2541
|
+
<!-- Hat -->
|
|
2542
|
+
<rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2543
|
+
<rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2544
|
+
<rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2545
|
+
<!-- Face -->
|
|
2546
|
+
<rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2547
|
+
<rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2548
|
+
<rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2549
|
+
<!-- Body -->
|
|
2550
|
+
<rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2551
|
+
<rect x="0" y="0" width="4" height="4" fill="#3498db"/>
|
|
2552
|
+
<rect x="4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2553
|
+
<!-- Arms raised -->
|
|
2554
|
+
<rect x="-8" y="-8" width="4" height="4" fill="#3498db"/>
|
|
2555
|
+
<rect x="8" y="-4" width="4" height="4" fill="#3498db"/>
|
|
2556
|
+
<!-- Lower body -->
|
|
2557
|
+
<rect x="-4" y="4" width="4" height="4" fill="#3498db"/>
|
|
2558
|
+
<rect x="0" y="4" width="4" height="4" fill="#3498db"/>
|
|
2559
|
+
<rect x="4" y="4" width="4" height="4" fill="#3498db"/>
|
|
2560
|
+
<!-- Legs -->
|
|
2561
|
+
<rect x="-4" y="8" width="4" height="4" fill="#2c3e50"/>
|
|
2562
|
+
<rect x="4" y="8" width="4" height="4" fill="#2c3e50"/>
|
|
2563
|
+
</g>
|
|
2564
|
+
`;
|
|
2565
|
+
} else if (period === 'day') {
|
|
2566
|
+
// Walking pose with backpack
|
|
2567
|
+
sherpaHtml = `
|
|
2568
|
+
<g transform="translate(355, 166)">
|
|
2569
|
+
<!-- Hat -->
|
|
2570
|
+
<rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2571
|
+
<rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2572
|
+
<rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2573
|
+
<!-- Face -->
|
|
2574
|
+
<rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2575
|
+
<rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2576
|
+
<rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2577
|
+
<!-- Body -->
|
|
2578
|
+
<rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2579
|
+
<rect x="0" y="0" width="4" height="4" fill="#3498db"/>
|
|
2580
|
+
<rect x="4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2581
|
+
<rect x="-4" y="4" width="4" height="4" fill="#3498db"/>
|
|
2582
|
+
<rect x="0" y="4" width="4" height="4" fill="#3498db"/>
|
|
2583
|
+
<rect x="4" y="4" width="4" height="4" fill="#3498db"/>
|
|
2584
|
+
<!-- Backpack -->
|
|
2585
|
+
<rect x="8" y="0" width="4" height="4" fill="#8b6914"/>
|
|
2586
|
+
<rect x="8" y="4" width="4" height="4" fill="#8b6914"/>
|
|
2587
|
+
<!-- Legs (walking) -->
|
|
2588
|
+
<rect x="-4" y="8" width="4" height="4" fill="#2c3e50"/>
|
|
2589
|
+
<rect x="0" y="8" width="4" height="4" fill="#2c3e50"/>
|
|
2590
|
+
<rect x="4" y="12" width="4" height="4" fill="#2c3e50"/>
|
|
2591
|
+
<rect x="-4" y="12" width="4" height="4" fill="#2c3e50"/>
|
|
2592
|
+
</g>
|
|
2593
|
+
`;
|
|
2594
|
+
} else {
|
|
2595
|
+
// Sitting with mug
|
|
2596
|
+
sherpaHtml = `
|
|
2597
|
+
<g transform="translate(345, 170)">
|
|
2598
|
+
<!-- Hat -->
|
|
2599
|
+
<rect x="0" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2600
|
+
<rect x="-4" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2601
|
+
<rect x="4" y="-8" width="4" height="4" fill="#e74c3c"/>
|
|
2602
|
+
<!-- Face -->
|
|
2603
|
+
<rect x="-4" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2604
|
+
<rect x="0" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2605
|
+
<rect x="4" y="-4" width="4" height="4" fill="#f5c6a0"/>
|
|
2606
|
+
<!-- Body -->
|
|
2607
|
+
<rect x="-4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2608
|
+
<rect x="0" y="0" width="4" height="4" fill="#3498db"/>
|
|
2609
|
+
<rect x="4" y="0" width="4" height="4" fill="#3498db"/>
|
|
2610
|
+
<!-- Backpack -->
|
|
2611
|
+
<rect x="8" y="0" width="4" height="4" fill="#8b6914"/>
|
|
2612
|
+
<!-- Sitting legs -->
|
|
2613
|
+
<rect x="-4" y="4" width="4" height="4" fill="#2c3e50"/>
|
|
2614
|
+
<rect x="0" y="4" width="4" height="4" fill="#2c3e50"/>
|
|
2615
|
+
<!-- Mug (pixel) -->
|
|
2616
|
+
<rect x="-8" y="0" width="${P}" height="${P}" fill="#ddd"/>
|
|
2617
|
+
<rect x="-12" y="0" width="${P}" height="${P}" fill="#ddd" opacity="0.5"/>
|
|
2618
|
+
<!-- Steam (pixel) -->
|
|
2619
|
+
<rect x="-8" y="-4" width="${P}" height="${P}" fill="#4a5170" opacity="0.2">
|
|
2620
|
+
<animate attributeName="opacity" values="0.2;0.05;0.2" dur="2s" repeatCount="indefinite"/>
|
|
2621
|
+
</rect>
|
|
2622
|
+
</g>
|
|
2623
|
+
`;
|
|
2624
|
+
}
|
|
1842
2625
|
|
|
1843
|
-
//
|
|
1844
|
-
|
|
1845
|
-
|
|
2626
|
+
// --- Distant climber (day and evening only) ---
|
|
2627
|
+
let distantClimber = '';
|
|
2628
|
+
if (period === 'day' || period === 'evening') {
|
|
2629
|
+
const climbOpacity = period === 'day' ? 0.6 : 0.5;
|
|
2630
|
+
distantClimber = `
|
|
2631
|
+
<g transform="translate(612, 148)" opacity="${climbOpacity}">
|
|
2632
|
+
<rect x="0" y="0" width="${P}" height="${P}" fill="#f5c6a0"/>
|
|
2633
|
+
<rect x="0" y="4" width="${P}" height="${P}" fill="#c84040"/>
|
|
2634
|
+
<rect x="0" y="8" width="${P}" height="${P}" fill="#2c3e50"/>
|
|
2635
|
+
<rect x="4" y="0" width="${P}" height="${P}" fill="#8090b0" opacity="0.5"/>
|
|
2636
|
+
<rect x="4" y="4" width="${P}" height="${P}" fill="#8090b0" opacity="0.4"/>
|
|
2637
|
+
</g>
|
|
2638
|
+
`;
|
|
2639
|
+
}
|
|
1846
2640
|
|
|
1847
|
-
//
|
|
1848
|
-
|
|
1849
|
-
|
|
2641
|
+
// --- Tent glow (dawn only) ---
|
|
2642
|
+
let tentGlow = '';
|
|
2643
|
+
if (period === 'dawn') {
|
|
2644
|
+
tentGlow = `<rect x="88" y="180" width="4" height="4" fill="#ffcc44" opacity="0.3"/>`;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// --- Sunrise glow (morning only) ---
|
|
2648
|
+
let sunriseGlow = '';
|
|
2649
|
+
if (period === 'morning') {
|
|
2650
|
+
sunriseGlow = `<rect x="360" y="140" width="280" height="60" fill="#e8834a" opacity="0.06"/>
|
|
2651
|
+
<rect x="400" y="160" width="200" height="40" fill="#f5a060" opacity="0.05"/>`;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// --- Assemble SVG ---
|
|
2655
|
+
const svgString = `
|
|
2656
|
+
<svg viewBox="0 0 680 220" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" style="image-rendering:pixelated">
|
|
2657
|
+
<defs>
|
|
2658
|
+
<linearGradient id="bc-sky" x1="0" y1="0" x2="0" y2="1">
|
|
2659
|
+
${stops}
|
|
2660
|
+
</linearGradient>
|
|
2661
|
+
</defs>
|
|
2662
|
+
|
|
2663
|
+
<!-- Sky -->
|
|
2664
|
+
<rect width="680" height="220" fill="url(#bc-sky)"/>
|
|
2665
|
+
|
|
2666
|
+
${sunriseGlow}
|
|
2667
|
+
|
|
2668
|
+
<!-- Stars / clouds -->
|
|
2669
|
+
${starsHtml}
|
|
2670
|
+
|
|
2671
|
+
<!-- Far range mountains -->
|
|
2672
|
+
<polygon points="${farRangePoly}" fill="${theme.farRange}"/>
|
|
2673
|
+
|
|
2674
|
+
<!-- Snow caps -->
|
|
2675
|
+
${snowCaps}
|
|
2676
|
+
|
|
2677
|
+
<!-- Mid range mountains -->
|
|
2678
|
+
<polygon points="${midRangePoly}" fill="${theme.midRange}"/>
|
|
2679
|
+
|
|
2680
|
+
<!-- Ground -->
|
|
2681
|
+
${groundHtml}
|
|
2682
|
+
|
|
2683
|
+
<!-- Tent glow -->
|
|
2684
|
+
${tentGlow}
|
|
2685
|
+
|
|
2686
|
+
<!-- Tents -->
|
|
2687
|
+
${tents}
|
|
2688
|
+
|
|
2689
|
+
<!-- Prayer flags -->
|
|
2690
|
+
${prayerFlags1}
|
|
2691
|
+
${prayerFlags2}
|
|
2692
|
+
|
|
2693
|
+
<!-- Supplies & gear -->
|
|
2694
|
+
${supplies}
|
|
2695
|
+
|
|
2696
|
+
<!-- Campfire -->
|
|
2697
|
+
${campfireHtml}
|
|
2698
|
+
|
|
2699
|
+
<!-- Sherpa -->
|
|
2700
|
+
${sherpaHtml}
|
|
2701
|
+
|
|
2702
|
+
<!-- Distant climber -->
|
|
2703
|
+
${distantClimber}
|
|
2704
|
+
</svg>
|
|
2705
|
+
`;
|
|
2706
|
+
|
|
2707
|
+
container.innerHTML = svgString;
|
|
2708
|
+
}
|
|
1850
2709
|
|
|
1851
2710
|
function startSherpaQuotes() {
|
|
1852
2711
|
const el = document.getElementById('sherpa-quote');
|
|
1853
2712
|
if (!el) return;
|
|
1854
2713
|
|
|
1855
|
-
|
|
1856
|
-
let quotes = [...SHERPA_QUOTES];
|
|
1857
|
-
for (let i = quotes.length - 1; i > 0; i--) {
|
|
1858
|
-
const j = Math.floor(Math.random() * (i + 1));
|
|
1859
|
-
[quotes[i], quotes[j]] = [quotes[j], quotes[i]];
|
|
1860
|
-
}
|
|
2714
|
+
const isFirstVisit = !localStorage.getItem(ONBOARDING_KEY);
|
|
1861
2715
|
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
2716
|
+
if (isFirstVisit) {
|
|
2717
|
+
// First visit: start with tap prompt, then guide on click
|
|
2718
|
+
sherpaMode = 'intro';
|
|
2719
|
+
el.textContent = '나를 눌러보세유~';
|
|
2720
|
+
} else {
|
|
2721
|
+
setSherpaMode('grumpy');
|
|
2722
|
+
}
|
|
1865
2723
|
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
// Reshuffle
|
|
1872
|
-
for (let i = quotes.length - 1; i > 0; i--) {
|
|
1873
|
-
const j = Math.floor(Math.random() * (i + 1));
|
|
1874
|
-
[quotes[i], quotes[j]] = [quotes[j], quotes[i]];
|
|
1875
|
-
}
|
|
1876
|
-
idx = 0;
|
|
1877
|
-
}
|
|
1878
|
-
el.textContent = quotes[idx];
|
|
1879
|
-
el.style.opacity = '1';
|
|
1880
|
-
}, 500);
|
|
2724
|
+
// Start rotation timer
|
|
2725
|
+
if (sherpaInterval) clearInterval(sherpaInterval);
|
|
2726
|
+
sherpaInterval = setInterval(() => {
|
|
2727
|
+
if (sherpaMode === 'intro') return; // Don't rotate during intro
|
|
2728
|
+
advanceSherpa();
|
|
1881
2729
|
}, 8000);
|
|
1882
2730
|
}
|
|
1883
2731
|
|
|
2732
|
+
window.toggleSherpaMode = function() {
|
|
2733
|
+
const speech = document.getElementById('sherpa-speech');
|
|
2734
|
+
const el = document.getElementById('sherpa-quote');
|
|
2735
|
+
if (!el || !speech) return;
|
|
2736
|
+
|
|
2737
|
+
if (sherpaMode === 'intro') {
|
|
2738
|
+
// First click ever: enter guide mode
|
|
2739
|
+
setSherpaMode('guide');
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// Toggle between guide and grumpy
|
|
2744
|
+
const newMode = sherpaMode === 'guide' ? 'grumpy' : 'guide';
|
|
2745
|
+
|
|
2746
|
+
// Brief mode-switch message
|
|
2747
|
+
el.style.opacity = '0';
|
|
2748
|
+
setTimeout(() => {
|
|
2749
|
+
if (newMode === 'guide') {
|
|
2750
|
+
el.textContent = '가이드 모드여유~ 사용법 알려줄겨 📋';
|
|
2751
|
+
} else {
|
|
2752
|
+
el.textContent = '다시 푸념 모드여유... 😮💨';
|
|
2753
|
+
}
|
|
2754
|
+
el.style.opacity = '1';
|
|
2755
|
+
|
|
2756
|
+
setTimeout(() => {
|
|
2757
|
+
setSherpaMode(newMode);
|
|
2758
|
+
}, 2000);
|
|
2759
|
+
}, 300);
|
|
2760
|
+
};
|
|
2761
|
+
|
|
1884
2762
|
// ---------------------------------------------------------------------------
|
|
1885
2763
|
// Activity Trail
|
|
1886
2764
|
// ---------------------------------------------------------------------------
|
|
@@ -1961,9 +2839,11 @@ async function loadActivityTrail() {
|
|
|
1961
2839
|
}
|
|
1962
2840
|
});
|
|
1963
2841
|
|
|
1964
|
-
// Tents on rest days
|
|
2842
|
+
// Tents on rest days — limit density when most days are rest days
|
|
2843
|
+
const activeDays = days.filter(d => d.commits > 0).length;
|
|
2844
|
+
const tentChance = activeDays < 5 ? 0.85 : 0.5; // fewer active days → fewer tents
|
|
1965
2845
|
heights.forEach((h, i) => {
|
|
1966
|
-
if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() >
|
|
2846
|
+
if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > tentChance) {
|
|
1967
2847
|
const x = 20 + i * dayW + dayW / 2 - 4;
|
|
1968
2848
|
decorations += `
|
|
1969
2849
|
<rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
|
|
@@ -1979,9 +2859,9 @@ async function loadActivityTrail() {
|
|
|
1979
2859
|
if (datePrs && datePrs.length > 0) {
|
|
1980
2860
|
const x = 20 + i * dayW + dayW / 2 - 4;
|
|
1981
2861
|
const h = heights[i];
|
|
1982
|
-
const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
|
|
2862
|
+
const tooltipText = datePrs.map(p => `#${p.number} ${escHtml(p.title)}`).join('\n');
|
|
1983
2863
|
prMarkers += `
|
|
1984
|
-
<g class="pr-marker" data-tooltip="${tooltipText
|
|
2864
|
+
<g class="pr-marker" data-tooltip="${escHtml(tooltipText)}">
|
|
1985
2865
|
<rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
|
|
1986
2866
|
<rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
|
|
1987
2867
|
<rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
|
|
@@ -2014,8 +2894,8 @@ async function loadActivityTrail() {
|
|
|
2014
2894
|
<polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
|
|
2015
2895
|
}
|
|
2016
2896
|
|
|
2017
|
-
// Sherpa at today (last position)
|
|
2018
|
-
const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
|
|
2897
|
+
// Sherpa at today (last position, clamped to SVG bounds)
|
|
2898
|
+
const lastX = Math.min(20 + (days.length - 1) * dayW + dayW / 2 - 4, svgW - 24);
|
|
2019
2899
|
const lastH = heights[heights.length - 1];
|
|
2020
2900
|
const sherpaY = lastH - 16;
|
|
2021
2901
|
const sherpa = `
|
|
@@ -2137,12 +3017,11 @@ async function init() {
|
|
|
2137
3017
|
}
|
|
2138
3018
|
renderAll();
|
|
2139
3019
|
loadPortal();
|
|
3020
|
+
renderBasecampScene();
|
|
2140
3021
|
startSherpaQuotes();
|
|
2141
3022
|
loadActivityTrail();
|
|
2142
3023
|
connectWs();
|
|
2143
3024
|
|
|
2144
|
-
// Show onboarding for first-time users
|
|
2145
|
-
setTimeout(showOnboarding, 500);
|
|
2146
3025
|
}
|
|
2147
3026
|
|
|
2148
3027
|
document.addEventListener('DOMContentLoaded', init);
|