SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import type { NetworkModel } from './inpModel';
|
||||
import type { BasemapType, Scenario, SimulationResult, UserGisLayer } from './types';
|
||||
import type { CRS } from './crs';
|
||||
import type { DisplayUnitMode } from './unitSystem';
|
||||
import type { MapStyleSettings } from './StyleSettingsPanel';
|
||||
|
||||
// Legacy "GISPipe" prefix kept on purpose (here and in the gispipe_* localStorage
|
||||
// keys): renaming storage keys would drop every user's saved session/settings.
|
||||
const DATABASE_NAME = 'gispipe-session';
|
||||
const DATABASE_VERSION = 1;
|
||||
const STORE_NAME = 'autosave';
|
||||
const LATEST_KEY = 'latest';
|
||||
const TAB_KEY_STORAGE_NAME = 'gispipe_tab_session_key';
|
||||
// Autosave keeps one record per browser tab (see getTabSessionKey), so the
|
||||
// store is trimmed to the most recently-saved tabs to avoid growing forever
|
||||
// as tabs are opened and closed over time.
|
||||
const MAX_RETAINED_SNAPSHOTS = 10;
|
||||
|
||||
export interface SessionSnapshot {
|
||||
version: 2;
|
||||
savedAt: string;
|
||||
fileName: string | null;
|
||||
networkModel: NetworkModel;
|
||||
crs: CRS;
|
||||
basemap: BasemapType;
|
||||
displayUnitMode: DisplayUnitMode;
|
||||
styleSettings: MapStyleSettings;
|
||||
isModified: boolean;
|
||||
// Full project state -- without these, restoring a session silently lost
|
||||
// every alternative scenario, its results, and any GIS reference layers.
|
||||
rawInp: string | null;
|
||||
scenarios: Scenario[];
|
||||
activeScenarioId: string;
|
||||
wasmResult: SimulationResult | null;
|
||||
serverResult: SimulationResult | null;
|
||||
resultInpSnapshot: string | null;
|
||||
userLayers: UserGisLayer[];
|
||||
}
|
||||
|
||||
/** Pre-P0-8 snapshot shape, kept only so an old autosave record from a
|
||||
* previous session doesn't hard-fail to load; it restores with no
|
||||
* scenarios/results/layers rather than being discarded outright. */
|
||||
interface LegacySessionSnapshotV1 {
|
||||
version: 1;
|
||||
savedAt: string;
|
||||
fileName: string | null;
|
||||
networkModel: NetworkModel;
|
||||
crs: CRS;
|
||||
basemap: BasemapType;
|
||||
displayUnitMode: DisplayUnitMode;
|
||||
styleSettings: MapStyleSettings;
|
||||
isModified: boolean;
|
||||
}
|
||||
|
||||
type StoredSnapshot = (SessionSnapshot | LegacySessionSnapshotV1) & { id: string };
|
||||
|
||||
/**
|
||||
* A stable id for this browser TAB (sessionStorage, unlike localStorage/
|
||||
* IndexedDB, is private per tab and survives reloads of that same tab but
|
||||
* not a fresh tab/window). Using it as the IndexedDB record key means
|
||||
* multiple tabs autosaving concurrently each get their own record instead of
|
||||
* clobbering a single shared 'latest' record.
|
||||
*/
|
||||
function getTabSessionKey(): string {
|
||||
try {
|
||||
let key = sessionStorage.getItem(TAB_KEY_STORAGE_NAME);
|
||||
if (!key) {
|
||||
key = `tab_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
sessionStorage.setItem(TAB_KEY_STORAGE_NAME, key);
|
||||
}
|
||||
return key;
|
||||
} catch {
|
||||
// sessionStorage unavailable (e.g. private-mode restrictions): fall back
|
||||
// to the old shared key rather than failing autosave entirely.
|
||||
return LATEST_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
function openSessionDatabase(): Promise<IDBDatabase> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<IDBDatabase>();
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB 열기 실패'));
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB 요청 실패'));
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function saveSessionSnapshot(snapshot: SessionSnapshot): Promise<void> {
|
||||
const database = await openSessionDatabase();
|
||||
try {
|
||||
const key = getTabSessionKey();
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장 실패'));
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('자동 저장 중단'));
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const stored: StoredSnapshot = { ...snapshot, id: key };
|
||||
store.put(stored);
|
||||
|
||||
// Trim to the most recently-saved MAX_RETAINED_SNAPSHOTS records so
|
||||
// autosaving from many tabs over time doesn't grow the store forever.
|
||||
const allRequest = store.getAll();
|
||||
allRequest.onsuccess = () => {
|
||||
const all = (allRequest.result as StoredSnapshot[]) || [];
|
||||
if (all.length > MAX_RETAINED_SNAPSHOTS) {
|
||||
const sorted = [...all].sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
|
||||
for (const stale of sorted.slice(MAX_RETAINED_SNAPSHOTS)) {
|
||||
store.delete(stale.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
await promise;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for unit testing the v1 -> v2 migration without a real IndexedDB. */
|
||||
export function normalizeStoredSnapshot(stored: StoredSnapshot): SessionSnapshot | null {
|
||||
if (!stored || !stored.networkModel) return null;
|
||||
if (stored.version === 2) {
|
||||
const { id: _id, ...snapshot } = stored;
|
||||
return snapshot;
|
||||
}
|
||||
if (stored.version === 1) {
|
||||
const { id: _id, ...legacy } = stored;
|
||||
return {
|
||||
...legacy,
|
||||
version: 2,
|
||||
rawInp: null,
|
||||
scenarios: [],
|
||||
activeScenarioId: 'base',
|
||||
wasmResult: null,
|
||||
serverResult: null,
|
||||
resultInpSnapshot: null,
|
||||
userLayers: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadSessionSnapshot(): Promise<SessionSnapshot | null> {
|
||||
const database = await openSessionDatabase();
|
||||
try {
|
||||
const key = getTabSessionKey();
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||
const stored = (await requestToPromise(
|
||||
transaction.objectStore(STORE_NAME).get(key)
|
||||
)) as StoredSnapshot | undefined;
|
||||
if (!stored) return null;
|
||||
return normalizeStoredSnapshot(stored);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSessionSnapshot(): Promise<void> {
|
||||
const database = await openSessionDatabase();
|
||||
try {
|
||||
const key = getTabSessionKey();
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장본 삭제 실패'));
|
||||
transaction.objectStore(STORE_NAME).delete(key);
|
||||
await promise;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user