부모 창 연동
부모는 팝업과 영구 저장을, 에디터는 도형 편집을 담당합니다. 일반 편집 연동에 Supabase 키나 로그인 토큰을 전달할 필요는 없습니다.
메시지 흐름
수신기를 먼저 등록하고 사용자 클릭에서 팝업을 엽니다. READY를 받은 뒤 INIT을 보내고, SUBMIT 또는 CANCEL을 처리합니다.
| 메시지 | 방향 | 필수 데이터·처리 |
|---|---|---|
MAP_EDITOR_READY | 에디터 → 부모 | 데이터 없는 준비 신호. 부모가 INIT으로 응답합니다. |
MAP_EDITOR_INIT | 부모 → 에디터 | sessionId + scene. 새 INIT은 현재 편집·히스토리를 초기화합니다. |
MAP_EDITOR_SUBMIT | 에디터 → 부모 | 같은 sessionId + 전체 scene. 검증 후 부모 상태에 반영하고 팝업을 닫습니다. |
MAP_EDITOR_CANCEL | 에디터 → 부모 | 같은 sessionId. scene 없이 종료하며 기존 부모 데이터를 유지합니다. |
MAP_EDITOR_ERROR | 에디터 → 부모 | message + 선택적 issues. sessionId가 없을 수 있습니다. |
세 가지를 함께 검증하세요
event.source는 내가 연 팝업, event.origin은 미리 정한 에디터 origin이어야 합니다. SUBMIT/CANCEL은 발급한 sessionId까지 일치해야 합니다. INIT에 targetOrigin="*"를 사용하지 않습니다.
입력과 출력은 같은 v2 형식
{ "version": 2, "id": "delivery-area-edit", "name": "배송 권역 편집", "viewport": { "center": [ 127.0276, 37.4979 ], "zoom": 13 }, "features": [ { "id": "service-area-1", "name": "강남 배송권역", "geometry": { "type": "Polygon", "coordinates": [ [ [ 127.01, 37.51 ], [ 127.05, 37.51 ], [ 127.05, 37.48 ], [ 127.01, 37.48 ], [ 127.01, 37.51 ] ] ] }, "properties": { "serviceAreaId": 42 } }, { "id": "store-1", "name": "강남점", "locked": true, "geometry": { "type": "Point", "coordinates": [ 127.0276, 37.4979 ] } } ]}필수는
version: 2,features, 각 도형의geometry입니다. 좌표는 WGS84의[경도, 위도]순서입니다.Point·MultiPoint·LineString·MultiLineString·Polygon·MultiPolygon을 지원합니다. GeometryCollection은 받지 않습니다.
선택 필드: 도형의 id·name·locked·visible·themeToken·properties, scene의 id·name·viewport. id를 보내면 중복되지 않아야 합니다.
배열 뒤쪽이 지도 위쪽입니다. 반환값에 내부 layers·selection·history는 없고, 숨긴 도형은 포함됩니다.
locked는 사용자 UI에서 해제할 수 있는 잠금 상태입니다. 변조 방지나 서버 접근 권한으로 사용하지 마세요.
완료 시 부모가 scene 전체를 교체합니다. 중간 변경 메시지나 자동 저장은 없습니다. 저장 가능 조건도 확인하세요.
복사해서 연결하는 예제
TypeScript + Zod 예제입니다. 아래 네 파일을 같은 폴더에 두고, 기존 부모 화면의 버튼·지도·오류 UI를 연결하세요.
npm install zod 후 bindMapEditor에 실제 editorUrl과 화면 콜백을 전달합니다. 반환된 정리 함수를 부모 화면의 unmount 시 호출하세요.
1. 부모 화면에서 연결하기
import type { EditorSceneInput } from "./editor-contract.example";import { inputScene } from "./input-scene.example";import { createMapEditorHost } from "./map-editor-host.example";
type ParentPageBindings = { // 예: https://maps-editor.pages.dev/editor/ 또는 로컬 /editor/ editorUrl: string; openButton: HTMLButtonElement; renderOnParentMap: (featureCollection: { type: "FeatureCollection"; features: Array<{ type: "Feature"; id?: string; geometry: EditorSceneInput["features"][number]["geometry"]; properties: Record<string, unknown>; }>; }) => void; showEditorError: (message: string) => void;};
export function bindMapEditor({ editorUrl, openButton, renderOnParentMap, showEditorError,}: ParentPageBindings) { let currentScene: EditorSceneInput = inputScene;
const mapEditor = createMapEditorHost({ editorUrl, getScene: () => currentScene, onSubmit(editedScene) { // 반환값 전체를 다음 편집의 기준 데이터로 교체합니다. currentScene = editedScene;
// 부모 화면의 지도·폼·상태 관리에는 features를 사용합니다. renderOnParentMap({ type: "FeatureCollection", features: editedScene.features.map((feature) => ({ type: "Feature", id: feature.id, geometry: feature.geometry, properties: { ...feature.properties, name: feature.name, locked: feature.locked, visible: feature.visible, }, })), }); }, onCancel() { // CANCEL에는 scene이 없으므로 기존 currentScene을 그대로 유지합니다. }, onError: showEditorError, });
const openEditor = () => { try { mapEditor.open(); } catch (error) { showEditorError( error instanceof Error ? error.message : "편집기를 열지 못했습니다.", ); } }; openButton.addEventListener("click", openEditor);
return () => { openButton.removeEventListener("click", openEditor); mapEditor.dispose(); };}2. 팝업·메시지 처리
import { completionMessageSchema, type EditorSceneInput,} from "./editor-contract.example";
const EDITOR_WINDOW_NAME = "map-editor-child";const EDITOR_WINDOW_FEATURES = "width=1280,height=860";
type MapEditorHostOptions = { editorUrl: string; getScene: () => EditorSceneInput; onSubmit: (scene: EditorSceneInput) => void; onCancel?: () => void; onError?: (message: string) => void;};
export function createMapEditorHost(options: MapEditorHostOptions) { const editorUrl = new URL(options.editorUrl, window.location.href); const editorOrigin = editorUrl.origin; let editorWindow: Window | null = null; let sessionId: string | null = null; let initialScene: EditorSceneInput | null = null;
const closeEditor = () => { editorWindow?.close(); editorWindow = null; sessionId = null; initialScene = null; };
const handleMessage = (event: MessageEvent<unknown>) => { // 반드시 내가 연 창과 배포된 편집기의 정확한 origin을 함께 확인합니다. const targetWindow = editorWindow; if ( !targetWindow || event.source !== targetWindow || event.origin !== editorOrigin ) { return; }
const data = event.data; if ( typeof data !== "object" || data === null || !("type" in data) || typeof data.type !== "string" ) { return; }
if (data.type === "MAP_EDITOR_READY") { // 같은 팝업의 재전송에도 세션과 최초 입력을 일관되게 유지합니다. if (!sessionId || !initialScene) return; targetWindow.postMessage( { type: "MAP_EDITOR_INIT", sessionId, scene: initialScene, }, editorOrigin, ); return; }
const completion = completionMessageSchema.safeParse(data); if (completion.success) { if (completion.data.sessionId !== sessionId) { return; }
closeEditor(); if (completion.data.type === "MAP_EDITOR_SUBMIT") { options.onSubmit(completion.data.scene); } else { options.onCancel?.(); } return; }
if (data.type === "MAP_EDITOR_ERROR") { const message = "message" in data && typeof data.message === "string" ? data.message : "지도 편집기에서 오류가 발생했습니다."; options.onError?.(message); } };
window.addEventListener("message", handleMessage);
return { open() { // 중복 클릭으로 이미 편집 중인 창을 닫거나 데이터를 덮지 않습니다. if (editorWindow && !editorWindow.closed) { editorWindow.focus(); return; } closeEditor(); initialScene = structuredClone(options.getScene()); sessionId = crypto.randomUUID(); editorWindow = window.open( editorUrl.href, EDITOR_WINDOW_NAME, EDITOR_WINDOW_FEATURES, );
if (!editorWindow) { closeEditor(); throw new Error("팝업이 차단되었습니다."); } }, dispose() { window.removeEventListener("message", handleMessage); closeEditor(); }, };}3. 결과 검증 스키마
import { z } from "zod";
// 외부 서비스로 복사할 수 있는 최소 검증 예제입니다. 업무별 저장 권한·면적 검증은 별도입니다.const coordinateSchema = z.tuple([ z.number().min(-180).max(180), z.number().min(-90).max(90),]);const lineStringCoordinatesSchema = z.array(coordinateSchema).min(2);const ringSchema = z .array(coordinateSchema) .min(3) .superRefine((ring, context) => { const first = ring[0]; const last = ring[ring.length - 1]; const closed = first && last && first[0] === last[0] && first[1] === last[1]; const vertices = closed ? ring.slice(0, -1) : ring; if ( (closed && ring.length < 4) || new Set(vertices.map((point) => point.join(","))).size < 3 ) { context.addIssue({ code: "custom", message: "Polygon에는 서로 다른 정점이 3개 이상 필요합니다.", }); } });const polygonCoordinatesSchema = z.array(ringSchema).min(1);
const geometrySchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("Point"), coordinates: coordinateSchema, }), z.object({ type: z.literal("MultiPoint"), coordinates: z.array(coordinateSchema).min(1), }), z.object({ type: z.literal("LineString"), coordinates: lineStringCoordinatesSchema, }), z.object({ type: z.literal("MultiLineString"), coordinates: z.array(lineStringCoordinatesSchema).min(1), }), z.object({ type: z.literal("Polygon"), coordinates: polygonCoordinatesSchema, }), z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(polygonCoordinatesSchema).min(1), }),]);
const featureInputSchema = z.object({ geometry: geometrySchema, id: z.string().optional(), name: z.string().optional(), locked: z.boolean().optional(), visible: z.boolean().optional(), themeToken: z.string().optional(), properties: z.record(z.string(), z.unknown()).optional(),});
export const editorSceneInputSchema = z.object({ version: z.literal(2), features: z.array(featureInputSchema), id: z.string().optional(), name: z.string().optional(), viewport: z .object({ center: coordinateSchema.optional(), zoom: z.number().optional(), }) .optional(),});
export const completionMessageSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("MAP_EDITOR_SUBMIT"), sessionId: z.string().min(1), scene: editorSceneInputSchema, }), z.object({ type: z.literal("MAP_EDITOR_CANCEL"), sessionId: z.string().min(1), }),]);
export type EditorSceneInput = z.infer<typeof editorSceneInputSchema>;좌표 범위와 링 구조를 검사하는 연동 예제이며 모든 업무 규칙을 대신하지 않습니다. 부모 서버에서도 면적·위치·식별자와 저장 권한을 검증하세요.
4. 입력 데이터
import type { EditorSceneInput } from "./editor-contract.example";
export const inputScene = { version: 2, id: "delivery-area-edit", name: "배송 권역 편집", viewport: { center: [127.0276, 37.4979], zoom: 13, }, features: [ { id: "service-area-1", name: "강남 배송권역", geometry: { type: "Polygon", coordinates: [ [ [127.01, 37.51], [127.05, 37.51], [127.05, 37.48], [127.01, 37.48], [127.01, 37.51], ], ], }, properties: { serviceAreaId: 42, }, }, { id: "store-1", name: "강남점", locked: true, geometry: { type: "Point", coordinates: [127.0276, 37.4979], }, }, ],} satisfies EditorSceneInput;팝업 연결을 유지하세요
noopener·noreferrer로 연 창이나 opener를 끊는 호스트 보안 정책은 현재 연결 방식과 맞지 않습니다. iframe 연동이 아닌 별도 창 방식입니다. Google 로그인은 에디터가 추가 팝업에서 처리하며 부모는 토큰을 받지 않습니다.
오류 처리
| 증상 | 확인할 것 |
|---|---|
| 창이 열리지 않음 | 사용자 클릭에서 window.open을 호출하고 팝업 차단 여부를 확인합니다. |
| READY를 받지 못함 | 수신기를 먼저 등록했는지, opener가 유지되는지, 에디터 URL·허용 부모 origin이 맞는지 확인합니다. |
| MAP_EDITOR_ERROR | 빈 sessionId, 중복 id, 좌표 순서·범위와 v2 형식을 확인합니다. 오류 메시지는 화면에 안전하게 표시합니다. |
| 편집 내용이 초기화됨 | 같은 창에 새 INIT을 계속 보내고 있지 않은지 확인합니다. INIT은 업데이트 이벤트가 아닙니다. |
| 결과를 받았는데 저장되지 않음 | SUBMIT 검증 뒤 부모의 저장 API를 호출해야 합니다. 예제는 부모 지도 반영까지만 수행합니다. |