- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
184 lines
6.8 KiB
TypeScript
184 lines
6.8 KiB
TypeScript
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();
|
|
}
|
|
}
|