From 9317111e3ab981a249b3e77e3070c628e7a0cc96 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 17 Aug 2026 18:58:46 -0400 Subject: [PATCH 01/22] feat(processing): add spatial workflow canvas --- .../processing/ModelBuilderDialog.tsx | 138 ++- .../geolibre-desktop/src/i18n/locales/ar.json | 23 +- .../geolibre-desktop/src/i18n/locales/de.json | 23 +- .../geolibre-desktop/src/i18n/locales/en.json | 3 + .../geolibre-desktop/src/i18n/locales/es.json | 23 +- .../geolibre-desktop/src/i18n/locales/fa.json | 23 +- .../geolibre-desktop/src/i18n/locales/fr.json | 23 +- .../geolibre-desktop/src/i18n/locales/hi.json | 23 +- .../geolibre-desktop/src/i18n/locales/id.json | 23 +- .../geolibre-desktop/src/i18n/locales/it.json | 23 +- .../geolibre-desktop/src/i18n/locales/ja.json | 23 +- .../geolibre-desktop/src/i18n/locales/ka.json | 23 +- .../geolibre-desktop/src/i18n/locales/ko.json | 23 +- .../geolibre-desktop/src/i18n/locales/nl.json | 23 +- .../geolibre-desktop/src/i18n/locales/pt.json | 23 +- .../geolibre-desktop/src/i18n/locales/ru.json | 23 +- .../geolibre-desktop/src/i18n/locales/th.json | 23 +- .../geolibre-desktop/src/i18n/locales/tr.json | 23 +- .../geolibre-desktop/src/i18n/locales/vi.json | 983 +++++++++--------- .../geolibre-desktop/src/i18n/locales/zh.json | 23 +- .../src/lib/processing-pipeline.ts | 106 ++ tests/processing-pipeline.test.ts | 51 + 22 files changed, 1009 insertions(+), 663 deletions(-) create mode 100644 apps/geolibre-desktop/src/lib/processing-pipeline.ts create mode 100644 tests/processing-pipeline.test.ts diff --git a/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx b/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx index 791905d3b2..18b4ea2acf 100644 --- a/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx @@ -17,6 +17,7 @@ import { type RunnerHost, } from "@geolibre/processing"; import { createDuckDbCapability } from "../../lib/duckdb-processing"; +import { modelToPipeline, pipelineToModel } from "../../lib/processing-pipeline"; import { Button, Dialog, @@ -35,12 +36,14 @@ import { ParameterField } from "./ParameterField"; import { ArrowDown, ArrowUp, + Download, Layers, Loader2, Play, Plus, Save, Trash2, + Upload, Workflow, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; @@ -176,7 +179,7 @@ export function ModelBuilderDialog({ mapControllerRef }: ModelBuilderDialogProps if (!next) setOpen(false); }} > - + {t("processing.modelBuilder.title")} {t("processing.modelBuilder.description")} @@ -499,6 +502,8 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement const [addToolId, setAddToolId] = useState(VECTOR_TOOLS[0].id); const [log, setLog] = useState([]); const [running, setRunning] = useState(false); + const [selectedStepId, setSelectedStepId] = useState(null); + const importRef = useRef(null); const appendLog = useCallback((message: string) => setLog((prev) => [...prev, message]), []); const fieldsByLayer = useFieldsByLayer(layers, true); @@ -506,6 +511,7 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement const newDraft = useCallback(() => { setDraft({ id: createId(), name: "Untitled model", steps: [] }); + setSelectedStepId(null); setLog([]); }, []); @@ -516,16 +522,19 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement name: model.name, steps: model.steps.map((s) => ({ ...s, parameters: { ...s.parameters } })), }); + setSelectedStepId(model.steps[0]?.id ?? null); setLog([]); }, []); const addStep = useCallback(() => { const tool = getVectorTool(addToolId); if (!tool) return; + const id = createId(); setDraft((prev) => ({ ...prev, - steps: [...prev.steps, { id: createId(), toolId: tool.id, parameters: defaultParams(tool) }], + steps: [...prev.steps, { id, toolId: tool.id, parameters: defaultParams(tool) }], })); + setSelectedStepId(id); }, [addToolId]); const removeStep = useCallback((stepId: string) => { @@ -533,8 +542,42 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement ...prev, steps: prev.steps.filter((s) => s.id !== stepId), })); + setSelectedStepId((current) => (current === stepId ? null : current)); }, []); + const handleExport = useCallback(() => { + const json = JSON.stringify(modelToPipeline(draft), null, 2); + const url = URL.createObjectURL(new Blob([json], { type: "application/json" })); + const anchor = document.createElement("a"); + const slug = draft.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + anchor.href = url; + anchor.download = `${slug || "pipeline"}.pipeline.json`; + anchor.click(); + URL.revokeObjectURL(url); + appendLog(`Exported ${anchor.download}`); + }, [draft, appendLog]); + + const handleImport = useCallback( + async (file: File) => { + try { + const model = pipelineToModel(JSON.parse(await file.text()), createId); + for (const step of model.steps) { + if (!getVectorTool(step.toolId)) throw new Error(`Unknown vector tool "${step.toolId}"`); + } + setDraft(model); + setSelectedStepId(model.steps[0]?.id ?? null); + setLog([`Imported ${file.name}`]); + } catch (error) { + appendLog(`Error: ${(error as Error).message}`); + } + }, + [appendLog], + ); + const moveStep = useCallback((stepId: string, dir: -1 | 1) => { setDraft((prev) => { const index = prev.steps.findIndex((s) => s.id === stepId); @@ -668,6 +711,12 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement /> + + {draft.steps.length === 0 ? (

@@ -686,6 +735,8 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement onParamChange={(paramId, value) => updateStepParam(step.id, paramId, value)} onRemove={() => removeStep(step.id)} onMove={(dir) => moveStep(step.id, dir)} + selected={step.id === selectedStepId} + onSelect={() => setSelectedStepId(step.id)} /> ))} @@ -714,6 +765,23 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement + + + { + const file = event.target.files?.[0]; + if (file) void handleImport(file); + event.target.value = ""; + }} + /> @@ -725,6 +793,63 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement ); } +/** Compact node-and-edge canvas for the ordered graph executed by the model runner. */ +function WorkflowCanvas({ + steps, + selectedStepId, + onSelect, +}: { + steps: ProcessingModelStep[]; + selectedStepId: string | null; + onSelect: (id: string) => void; +}): ReactElement { + const { t } = useTranslation(); + return ( +

+ {steps.length === 0 ? ( +

+ {t("processing.modelBuilder.emptyPipelineHint")} +

+ ) : ( +
+ {steps.map((step, index) => { + const tool = getVectorTool(step.toolId); + return ( +
+ {index > 0 ? ( + + )} +
+ ); +} + interface StepCardProps { step: ProcessingModelStep; index: number; @@ -734,6 +859,8 @@ interface StepCardProps { onParamChange: (paramId: string, value: unknown) => void; onRemove: () => void; onMove: (dir: -1 | 1) => void; + selected: boolean; + onSelect: () => void; } /** One step in the model editor: its tool, parameters, and reorder controls. */ @@ -746,6 +873,8 @@ function StepCard({ onParamChange, onRemove, onMove, + selected, + onSelect, }: StepCardProps): ReactElement { const { t } = useTranslation(); const tool = getVectorTool(step.toolId); @@ -775,7 +904,10 @@ function StepCard({ }; return ( -
+
{index + 1}. {tool?.name ?? step.toolId} diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index ea8ffeaa46..f383dda043 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -1795,6 +1795,16 @@ "exportCsv": "تصدير CSV", "exportGeoParquet": "تصدير GeoParquet" }, + "auth": { + "unavailableTitle": "تسجيل الدخول غير متاح", + "unavailableDescription": "تعذّر على GeoLibre الوصول إلى خدمة تسجيل الدخول، لذا لا يمكنه معرفة ما إذا كنت قد سجّلت الدخول. عادةً ما يكون هذا مؤقتًا؛ وإذا استمر، فقد تحتاج إعدادات المصادقة في هذا النشر إلى المراجعة.", + "retry": "إعادة المحاولة", + "signInTitle": "تسجيل الدخول إلى GeoLibre", + "signInDescription": "يتطلب هذا النشر حسابًا. سيتم نقلك إلى صفحة تسجيل دخول آمنة ثم إعادتك إلى هنا.", + "signIn": "تسجيل الدخول", + "signOut": "تسجيل الخروج", + "account": "الحساب" + }, "basemapExtract": { "title": "استخراج خريطة أساس دون اتصال", "url": "عنوان URL لخريطة الأساس", @@ -4312,6 +4322,9 @@ "addStep": "إضافة خطوة", "runModel": "تشغيل النموذج", "deleteModel": "حذف", + "canvas": "لوحة سير العمل المكاني", + "importPipeline": "استيراد خط الأنابيب", + "exportPipeline": "تصدير خط الأنابيب", "inputPreviousStep": "الإدخال: → ناتج الخطوة السابقة", "unknownTool": "أداة غير معروفة \"{{id}}\"", "noParameters": "لا توجد معاملات." @@ -5904,15 +5917,5 @@ "mapPoint": "نقطة على الخريطة", "reply": "رد", "replyPlaceholder": "اكتب ردًا..." - }, - "auth": { - "unavailableTitle": "تسجيل الدخول غير متاح", - "unavailableDescription": "تعذّر على GeoLibre الوصول إلى خدمة تسجيل الدخول، لذا لا يمكنه معرفة ما إذا كنت قد سجّلت الدخول. عادةً ما يكون هذا مؤقتًا؛ وإذا استمر، فقد تحتاج إعدادات المصادقة في هذا النشر إلى المراجعة.", - "retry": "إعادة المحاولة", - "signInTitle": "تسجيل الدخول إلى GeoLibre", - "signInDescription": "يتطلب هذا النشر حسابًا. سيتم نقلك إلى صفحة تسجيل دخول آمنة ثم إعادتك إلى هنا.", - "signIn": "تسجيل الدخول", - "signOut": "تسجيل الخروج", - "account": "الحساب" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 789be7952a..cebdbc60cc 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -1624,6 +1624,16 @@ "exportCsv": "CSV exportieren", "exportGeoParquet": "GeoParquet exportieren" }, + "auth": { + "unavailableTitle": "Anmeldung ist nicht verfügbar", + "unavailableDescription": "GeoLibre konnte den Anmeldedienst nicht erreichen und kann daher nicht feststellen, ob Sie angemeldet sind. Das ist meist vorübergehend; falls es weiterhin auftritt, müssen möglicherweise die Authentifizierungseinstellungen dieser Bereitstellung geprüft werden.", + "retry": "Erneut versuchen", + "signInTitle": "Bei GeoLibre anmelden", + "signInDescription": "Diese Bereitstellung erfordert ein Konto. Sie werden zu einer sicheren Anmeldeseite weitergeleitet und danach hierher zurückgebracht.", + "signIn": "Anmelden", + "signOut": "Abmelden", + "account": "Konto" + }, "basemapExtract": { "title": "Offline-Basiskarte extrahieren", "url": "Basiskarten-URL", @@ -4045,6 +4055,9 @@ "addStep": "Schritt hinzufügen", "runModel": "Modell ausführen", "deleteModel": "Löschen", + "canvas": "Arbeitsablauf-Canvas", + "importPipeline": "Pipeline importieren", + "exportPipeline": "Pipeline exportieren", "inputPreviousStep": "Eingabe: ← Ausgabe des vorherigen Schritts", "unknownTool": "Unbekanntes Werkzeug „{{id}}“", "noParameters": "Keine Parameter." @@ -5585,15 +5598,5 @@ "mapPoint": "Kartenpunkt", "reply": "Antworten", "replyPlaceholder": "Antwort schreiben..." - }, - "auth": { - "unavailableTitle": "Anmeldung ist nicht verfügbar", - "unavailableDescription": "GeoLibre konnte den Anmeldedienst nicht erreichen und kann daher nicht feststellen, ob Sie angemeldet sind. Das ist meist vorübergehend; falls es weiterhin auftritt, müssen möglicherweise die Authentifizierungseinstellungen dieser Bereitstellung geprüft werden.", - "retry": "Erneut versuchen", - "signInTitle": "Bei GeoLibre anmelden", - "signInDescription": "Diese Bereitstellung erfordert ein Konto. Sie werden zu einer sicheren Anmeldeseite weitergeleitet und danach hierher zurückgebracht.", - "signIn": "Anmelden", - "signOut": "Abmelden", - "account": "Konto" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index b77bec724d..3273215010 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4055,6 +4055,9 @@ "addStep": "Add step", "runModel": "Run model", "deleteModel": "Delete", + "canvas": "Spatial workflow canvas", + "importPipeline": "Import pipeline", + "exportPipeline": "Export pipeline", "inputPreviousStep": "Input: ← previous step output", "unknownTool": "Unknown tool \"{{id}}\"", "noParameters": "No parameters." diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 2b00f72227..4585909258 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -1624,6 +1624,16 @@ "exportCsv": "Exportar CSV", "exportGeoParquet": "Exportar GeoParquet" }, + "auth": { + "unavailableTitle": "El inicio de sesión no está disponible", + "unavailableDescription": "GeoLibre no pudo conectar con el servicio de inicio de sesión, por lo que no puede saber si ha iniciado sesión. Esto suele ser temporal; si persiste, es posible que haya que revisar la configuración de autenticación de esta implementación.", + "retry": "Intentar de nuevo", + "signInTitle": "Inicie sesión en GeoLibre", + "signInDescription": "Esta implementación requiere una cuenta. Se le llevará a una página de inicio de sesión segura y volverá aquí.", + "signIn": "Iniciar sesión", + "signOut": "Cerrar sesión", + "account": "Cuenta" + }, "basemapExtract": { "title": "Extraer mapa base sin conexión", "url": "URL del mapa base", @@ -4045,6 +4055,9 @@ "addStep": "Añadir paso", "runModel": "Ejecutar el modelo", "deleteModel": "Eliminar", + "canvas": "Lienzo de flujo de trabajo espacial", + "importPipeline": "Importar canalización", + "exportPipeline": "Exportar canalización", "inputPreviousStep": "Entrada: ← salida del paso anterior", "unknownTool": "Herramienta desconocida «{{id}}»", "noParameters": "Sin parámetros." @@ -5585,15 +5598,5 @@ "mapPoint": "Punto del mapa", "reply": "Responder", "replyPlaceholder": "Escriba una respuesta..." - }, - "auth": { - "unavailableTitle": "El inicio de sesión no está disponible", - "unavailableDescription": "GeoLibre no pudo conectar con el servicio de inicio de sesión, por lo que no puede saber si ha iniciado sesión. Esto suele ser temporal; si persiste, es posible que haya que revisar la configuración de autenticación de esta implementación.", - "retry": "Intentar de nuevo", - "signInTitle": "Inicie sesión en GeoLibre", - "signInDescription": "Esta implementación requiere una cuenta. Se le llevará a una página de inicio de sesión segura y volverá aquí.", - "signIn": "Iniciar sesión", - "signOut": "Cerrar sesión", - "account": "Cuenta" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 1dde3c3ae1..604bc44c62 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -1624,6 +1624,16 @@ "exportCsv": "خروجی CSV", "exportGeoParquet": "خروجی GeoParquet" }, + "auth": { + "unavailableTitle": "ورود در دسترس نیست", + "unavailableDescription": "GeoLibre نتوانست به سرویس ورود دسترسی پیدا کند، بنابراین نمی‌داند که آیا وارد شده‌اید یا نه. این وضعیت معمولاً موقتی است؛ اگر ادامه یافت، شاید تنظیمات احراز هویت این استقرار نیاز به بررسی داشته باشد.", + "retry": "تلاش دوباره", + "signInTitle": "ورود به GeoLibre", + "signInDescription": "این استقرار به یک حساب نیاز دارد. به یک صفحهٔ ورود امن هدایت می‌شوید و سپس به اینجا بازمی‌گردید.", + "signIn": "ورود", + "signOut": "خروج", + "account": "حساب" + }, "basemapExtract": { "title": "استخراج نقشهٔ پایهٔ برون‌خط", "url": "نشانی نقشهٔ پایه", @@ -4045,6 +4055,9 @@ "addStep": "افزودن گام", "runModel": "اجرای مدل", "deleteModel": "حذف", + "canvas": "بوم گردش کار مکانی", + "importPipeline": "درون‌ریزی خط لوله", + "exportPipeline": "برون‌بری خط لوله", "inputPreviousStep": "ورودی: → خروجی گام پیشین", "unknownTool": "ابزار ناشناختهٔ «{{id}}»", "noParameters": "بدون پارامتر." @@ -5585,15 +5598,5 @@ "mapPoint": "نقطهٔ نقشه", "reply": "پاسخ", "replyPlaceholder": "پاسخی بنویسید..." - }, - "auth": { - "unavailableTitle": "ورود در دسترس نیست", - "unavailableDescription": "GeoLibre نتوانست به سرویس ورود دسترسی پیدا کند، بنابراین نمی‌داند که آیا وارد شده‌اید یا نه. این وضعیت معمولاً موقتی است؛ اگر ادامه یافت، شاید تنظیمات احراز هویت این استقرار نیاز به بررسی داشته باشد.", - "retry": "تلاش دوباره", - "signInTitle": "ورود به GeoLibre", - "signInDescription": "این استقرار به یک حساب نیاز دارد. به یک صفحهٔ ورود امن هدایت می‌شوید و سپس به اینجا بازمی‌گردید.", - "signIn": "ورود", - "signOut": "خروج", - "account": "حساب" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 4a75f79de8..d032264db3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -1624,6 +1624,16 @@ "exportCsv": "Exporter en CSV", "exportGeoParquet": "Exporter en GeoParquet" }, + "auth": { + "unavailableTitle": "La connexion est indisponible", + "unavailableDescription": "GeoLibre n'a pas pu joindre le service de connexion et ne peut donc pas savoir si vous êtes connecté. Ce problème est généralement temporaire ; s'il persiste, les paramètres d'authentification de ce déploiement méritent peut-être une vérification.", + "retry": "Réessayer", + "signInTitle": "Connectez-vous à GeoLibre", + "signInDescription": "Ce déploiement nécessite un compte. Vous serez redirigé vers une page de connexion sécurisée, puis ramené ici.", + "signIn": "Se connecter", + "signOut": "Se déconnecter", + "account": "Compte" + }, "basemapExtract": { "title": "Extraire un fond de carte hors ligne", "url": "URL du fond de carte", @@ -4045,6 +4055,9 @@ "addStep": "Ajouter une étape", "runModel": "Exécuter le modèle", "deleteModel": "Supprimer", + "canvas": "Canevas de flux de travail spatial", + "importPipeline": "Importer le pipeline", + "exportPipeline": "Exporter le pipeline", "inputPreviousStep": "Entrée : ← sortie de l'étape précédente", "unknownTool": "Outil inconnu « {{id}} »", "noParameters": "Aucun paramètre." @@ -5585,15 +5598,5 @@ "mapPoint": "Point de la carte", "reply": "Répondre", "replyPlaceholder": "Écrire une réponse..." - }, - "auth": { - "unavailableTitle": "La connexion est indisponible", - "unavailableDescription": "GeoLibre n'a pas pu joindre le service de connexion et ne peut donc pas savoir si vous êtes connecté. Ce problème est généralement temporaire ; s'il persiste, les paramètres d'authentification de ce déploiement méritent peut-être une vérification.", - "retry": "Réessayer", - "signInTitle": "Connectez-vous à GeoLibre", - "signInDescription": "Ce déploiement nécessite un compte. Vous serez redirigé vers une page de connexion sécurisée, puis ramené ici.", - "signIn": "Se connecter", - "signOut": "Se déconnecter", - "account": "Compte" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 50c1b5cbbd..036a715d0e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -1624,6 +1624,16 @@ "exportCsv": "CSV निर्यात करें", "exportGeoParquet": "GeoParquet निर्यात करें" }, + "auth": { + "unavailableTitle": "साइन इन उपलब्ध नहीं है", + "unavailableDescription": "GeoLibre साइन-इन सेवा तक नहीं पहुँच सका, इसलिए यह नहीं बता सकता कि आपने साइन इन किया है या नहीं। यह आमतौर पर अस्थायी होता है; यदि यह बना रहता है, तो इस परिनियोजन की प्रमाणीकरण सेटिंग्स पर ध्यान देने की आवश्यकता हो सकती है।", + "retry": "पुनः प्रयास करें", + "signInTitle": "GeoLibre में साइन इन करें", + "signInDescription": "इस परिनियोजन के लिए एक खाता आवश्यक है। आपको एक सुरक्षित साइन-इन पृष्ठ पर ले जाया जाएगा और फिर यहाँ वापस लाया जाएगा।", + "signIn": "साइन इन करें", + "signOut": "साइन आउट करें", + "account": "खाता" + }, "basemapExtract": { "title": "ऑफ़लाइन बेसमैप एक्सट्रैक्ट करें", "url": "बेसमैप URL", @@ -4045,6 +4055,9 @@ "addStep": "चरण जोड़ें", "runModel": "मॉडल चलाएँ", "deleteModel": "हटाएँ", + "canvas": "स्थानिक वर्कफ़्लो कैनवास", + "importPipeline": "पाइपलाइन आयात करें", + "exportPipeline": "पाइपलाइन निर्यात करें", "inputPreviousStep": "इनपुट: ← पिछले चरण का आउटपुट", "unknownTool": "अज्ञात टूल \"{{id}}\"", "noParameters": "कोई पैरामीटर नहीं।" @@ -5585,15 +5598,5 @@ "mapPoint": "मानचित्र बिंदु", "reply": "उत्तर दें", "replyPlaceholder": "उत्तर लिखें..." - }, - "auth": { - "unavailableTitle": "साइन इन उपलब्ध नहीं है", - "unavailableDescription": "GeoLibre साइन-इन सेवा तक नहीं पहुँच सका, इसलिए यह नहीं बता सकता कि आपने साइन इन किया है या नहीं। यह आमतौर पर अस्थायी होता है; यदि यह बना रहता है, तो इस परिनियोजन की प्रमाणीकरण सेटिंग्स पर ध्यान देने की आवश्यकता हो सकती है।", - "retry": "पुनः प्रयास करें", - "signInTitle": "GeoLibre में साइन इन करें", - "signInDescription": "इस परिनियोजन के लिए एक खाता आवश्यक है। आपको एक सुरक्षित साइन-इन पृष्ठ पर ले जाया जाएगा और फिर यहाँ वापस लाया जाएगा।", - "signIn": "साइन इन करें", - "signOut": "साइन आउट करें", - "account": "खाता" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 2dea7dbf70..fbf2298e8c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -1581,6 +1581,16 @@ "exportCsv": "Ekspor CSV", "exportGeoParquet": "Ekspor GeoParquet" }, + "auth": { + "unavailableTitle": "Masuk tidak tersedia", + "unavailableDescription": "GeoLibre tidak dapat menjangkau layanan masuk, sehingga tidak dapat mengetahui apakah Anda sudah masuk. Ini biasanya bersifat sementara; jika terus terjadi, pengaturan autentikasi penerapan ini mungkin perlu diperiksa.", + "retry": "Coba lagi", + "signInTitle": "Masuk ke GeoLibre", + "signInDescription": "Penerapan ini memerlukan akun. Anda akan dibawa ke halaman masuk yang aman lalu dikembalikan ke sini.", + "signIn": "Masuk", + "signOut": "Keluar", + "account": "Akun" + }, "basemapExtract": { "title": "Ekstrak Peta Dasar Offline", "url": "URL peta dasar", @@ -3978,6 +3988,9 @@ "addStep": "Tambah langkah", "runModel": "Jalankan model", "deleteModel": "Hapus", + "canvas": "Kanvas alur kerja spasial", + "importPipeline": "Impor pipeline", + "exportPipeline": "Ekspor pipeline", "inputPreviousStep": "Masukan: ← keluaran langkah sebelumnya", "unknownTool": "Alat tidak dikenal \"{{id}}\"", "noParameters": "Tidak ada parameter." @@ -5505,15 +5518,5 @@ "mapPoint": "Titik Peta", "reply": "Balas", "replyPlaceholder": "Tulis balasan..." - }, - "auth": { - "unavailableTitle": "Masuk tidak tersedia", - "unavailableDescription": "GeoLibre tidak dapat menjangkau layanan masuk, sehingga tidak dapat mengetahui apakah Anda sudah masuk. Ini biasanya bersifat sementara; jika terus terjadi, pengaturan autentikasi penerapan ini mungkin perlu diperiksa.", - "retry": "Coba lagi", - "signInTitle": "Masuk ke GeoLibre", - "signInDescription": "Penerapan ini memerlukan akun. Anda akan dibawa ke halaman masuk yang aman lalu dikembalikan ke sini.", - "signIn": "Masuk", - "signOut": "Keluar", - "account": "Akun" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 291cabfb08..dcf30426bb 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -1624,6 +1624,16 @@ "exportCsv": "Esporta CSV", "exportGeoParquet": "Esporta GeoParquet" }, + "auth": { + "unavailableTitle": "Accesso non disponibile", + "unavailableDescription": "GeoLibre non è riuscito a raggiungere il servizio di accesso, quindi non può sapere se hai effettuato l'accesso. Di solito è un problema temporaneo; se persiste, le impostazioni di autenticazione di questo deployment potrebbero richiedere attenzione.", + "retry": "Riprova", + "signInTitle": "Accedi a GeoLibre", + "signInDescription": "Questo deployment richiede un account. Verrai portato a una pagina di accesso sicura e poi riportato qui.", + "signIn": "Accedi", + "signOut": "Esci", + "account": "Account" + }, "basemapExtract": { "title": "Estrai mappa di base offline", "url": "URL della mappa di base", @@ -4045,6 +4055,9 @@ "addStep": "Aggiungi passaggio", "runModel": "Esegui il modello", "deleteModel": "Elimina", + "canvas": "Area del flusso di lavoro spaziale", + "importPipeline": "Importa pipeline", + "exportPipeline": "Esporta pipeline", "inputPreviousStep": "Input: ← output del passaggio precedente", "unknownTool": "Strumento sconosciuto «{{id}}»", "noParameters": "Nessun parametro." @@ -5585,15 +5598,5 @@ "mapPoint": "Punto sulla mappa", "reply": "Rispondi", "replyPlaceholder": "Scrivi una risposta..." - }, - "auth": { - "unavailableTitle": "Accesso non disponibile", - "unavailableDescription": "GeoLibre non è riuscito a raggiungere il servizio di accesso, quindi non può sapere se hai effettuato l'accesso. Di solito è un problema temporaneo; se persiste, le impostazioni di autenticazione di questo deployment potrebbero richiedere attenzione.", - "retry": "Riprova", - "signInTitle": "Accedi a GeoLibre", - "signInDescription": "Questo deployment richiede un account. Verrai portato a una pagina di accesso sicura e poi riportato qui.", - "signIn": "Accedi", - "signOut": "Esci", - "account": "Account" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 6ecf58cc23..918013461d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -1581,6 +1581,16 @@ "exportCsv": "CSVをエクスポート", "exportGeoParquet": "GeoParquetをエクスポート" }, + "auth": { + "unavailableTitle": "サインインを利用できません", + "unavailableDescription": "GeoLibre はサインインサービスに接続できなかったため、サインイン済みかどうかを判断できません。通常は一時的なものです。解消しない場合は、このデプロイの認証設定を確認する必要があるかもしれません。", + "retry": "再試行", + "signInTitle": "GeoLibre にサインイン", + "signInDescription": "このデプロイにはアカウントが必要です。安全なサインインページに移動し、その後ここに戻ります。", + "signIn": "サインイン", + "signOut": "サインアウト", + "account": "アカウント" + }, "basemapExtract": { "title": "オフラインベースマップを抽出", "url": "ベースマップURL", @@ -3978,6 +3988,9 @@ "addStep": "ステップを追加", "runModel": "モデルを実行", "deleteModel": "削除", + "canvas": "空間ワークフローキャンバス", + "importPipeline": "パイプラインをインポート", + "exportPipeline": "パイプラインをエクスポート", "inputPreviousStep": "入力: ← 前のステップの出力", "unknownTool": "不明なツール「{{id}}」", "noParameters": "パラメータはありません。" @@ -5505,15 +5518,5 @@ "mapPoint": "地図上のポイント", "reply": "返信", "replyPlaceholder": "返信を入力..." - }, - "auth": { - "unavailableTitle": "サインインを利用できません", - "unavailableDescription": "GeoLibre はサインインサービスに接続できなかったため、サインイン済みかどうかを判断できません。通常は一時的なものです。解消しない場合は、このデプロイの認証設定を確認する必要があるかもしれません。", - "retry": "再試行", - "signInTitle": "GeoLibre にサインイン", - "signInDescription": "このデプロイにはアカウントが必要です。安全なサインインページに移動し、その後ここに戻ります。", - "signIn": "サインイン", - "signOut": "サインアウト", - "account": "アカウント" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index ee2537b99d..bc7d28471f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -1624,6 +1624,16 @@ "exportCsv": "CSV-ის ექსპორტი", "exportGeoParquet": "GeoParquet-ის ექსპორტი" }, + "auth": { + "unavailableTitle": "შესვლა მიუწვდომელია", + "unavailableDescription": "GeoLibre-მა ვერ დაუკავშირდა შესვლის სერვისს, ამიტომ ვერ განსაზღვრავს, შესული ხართ თუ არა. ეს ჩვეულებრივ დროებითია; თუ პრობლემა გრძელდება, შესაძლოა ამ განთავსების ავთენტიფიკაციის პარამეტრები საჭიროებდეს შემოწმებას.", + "retry": "ხელახლა ცდა", + "signInTitle": "შედით GeoLibre-ში", + "signInDescription": "ეს განთავსება ანგარიშს მოითხოვს. გადახვალთ უსაფრთხო შესვლის გვერდზე და შემდეგ დაბრუნდებით აქ.", + "signIn": "შესვლა", + "signOut": "გასვლა", + "account": "ანგარიში" + }, "basemapExtract": { "title": "ოფლაინ ბაზური რუკის ამოღება", "url": "ბაზური რუკის URL", @@ -4045,6 +4055,9 @@ "addStep": "ნაბიჯის დამატება", "runModel": "მოდელის გაშვება", "deleteModel": "წაშლა", + "canvas": "სივრცითი სამუშაო ნაკადის ტილო", + "importPipeline": "კონვეიერის იმპორტი", + "exportPipeline": "კონვეიერის ექსპორტი", "inputPreviousStep": "შესატანი: ← წინა ნაბიჯის შედეგი", "unknownTool": "უცნობი ხელსაწყო „{{id}}“", "noParameters": "პარამეტრები არ არის." @@ -5585,15 +5598,5 @@ "mapPoint": "რუკის წერტილი", "reply": "პასუხი", "replyPlaceholder": "დაწერეთ პასუხი..." - }, - "auth": { - "unavailableTitle": "შესვლა მიუწვდომელია", - "unavailableDescription": "GeoLibre-მა ვერ დაუკავშირდა შესვლის სერვისს, ამიტომ ვერ განსაზღვრავს, შესული ხართ თუ არა. ეს ჩვეულებრივ დროებითია; თუ პრობლემა გრძელდება, შესაძლოა ამ განთავსების ავთენტიფიკაციის პარამეტრები საჭიროებდეს შემოწმებას.", - "retry": "ხელახლა ცდა", - "signInTitle": "შედით GeoLibre-ში", - "signInDescription": "ეს განთავსება ანგარიშს მოითხოვს. გადახვალთ უსაფრთხო შესვლის გვერდზე და შემდეგ დაბრუნდებით აქ.", - "signIn": "შესვლა", - "signOut": "გასვლა", - "account": "ანგარიში" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 58da529d70..011e399aac 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -1581,6 +1581,16 @@ "exportCsv": "CSV 내보내기", "exportGeoParquet": "GeoParquet 내보내기" }, + "auth": { + "unavailableTitle": "로그인을 사용할 수 없습니다", + "unavailableDescription": "GeoLibre가 로그인 서비스에 연결하지 못해 로그인 여부를 확인할 수 없습니다. 대개 일시적인 문제이며, 계속된다면 이 배포의 인증 설정을 확인해야 할 수 있습니다.", + "retry": "다시 시도", + "signInTitle": "GeoLibre에 로그인", + "signInDescription": "이 배포에는 계정이 필요합니다. 보안 로그인 페이지로 이동한 뒤 이곳으로 돌아옵니다.", + "signIn": "로그인", + "signOut": "로그아웃", + "account": "계정" + }, "basemapExtract": { "title": "오프라인 베이스맵 추출", "url": "베이스맵 URL", @@ -3978,6 +3988,9 @@ "addStep": "단계 추가", "runModel": "모델 실행", "deleteModel": "삭제", + "canvas": "공간 워크플로 캔버스", + "importPipeline": "파이프라인 가져오기", + "exportPipeline": "파이프라인 내보내기", "inputPreviousStep": "입력: ← 이전 단계의 출력", "unknownTool": "알 수 없는 도구 \"{{id}}\"", "noParameters": "매개변수가 없습니다." @@ -5505,15 +5518,5 @@ "mapPoint": "지도 지점", "reply": "답글", "replyPlaceholder": "답글을 입력하세요..." - }, - "auth": { - "unavailableTitle": "로그인을 사용할 수 없습니다", - "unavailableDescription": "GeoLibre가 로그인 서비스에 연결하지 못해 로그인 여부를 확인할 수 없습니다. 대개 일시적인 문제이며, 계속된다면 이 배포의 인증 설정을 확인해야 할 수 있습니다.", - "retry": "다시 시도", - "signInTitle": "GeoLibre에 로그인", - "signInDescription": "이 배포에는 계정이 필요합니다. 보안 로그인 페이지로 이동한 뒤 이곳으로 돌아옵니다.", - "signIn": "로그인", - "signOut": "로그아웃", - "account": "계정" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 9971601a57..902915e5e2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -1624,6 +1624,16 @@ "exportCsv": "CSV exporteren", "exportGeoParquet": "GeoParquet exporteren" }, + "auth": { + "unavailableTitle": "Aanmelden is niet beschikbaar", + "unavailableDescription": "GeoLibre kon de aanmeldservice niet bereiken en kan daarom niet vaststellen of u bent aangemeld. Dit is meestal tijdelijk; als het aanhoudt, vragen de authenticatie-instellingen van deze implementatie mogelijk om aandacht.", + "retry": "Opnieuw proberen", + "signInTitle": "Aanmelden bij GeoLibre", + "signInDescription": "Deze implementatie vereist een account. U wordt naar een beveiligde aanmeldpagina geleid en daarna hier teruggebracht.", + "signIn": "Aanmelden", + "signOut": "Afmelden", + "account": "Account" + }, "basemapExtract": { "title": "Offline basiskaart extraheren", "url": "Basiskaart-URL", @@ -4045,6 +4055,9 @@ "addStep": "Stap toevoegen", "runModel": "Model uitvoeren", "deleteModel": "Verwijderen", + "canvas": "Canvas voor ruimtelijke workflow", + "importPipeline": "Pipeline importeren", + "exportPipeline": "Pipeline exporteren", "inputPreviousStep": "Invoer: ← uitvoer van de vorige stap", "unknownTool": "Onbekend gereedschap ‘{{id}}’", "noParameters": "Geen parameters." @@ -5585,15 +5598,5 @@ "mapPoint": "Kaartpunt", "reply": "Beantwoorden", "replyPlaceholder": "Schrijf een antwoord..." - }, - "auth": { - "unavailableTitle": "Aanmelden is niet beschikbaar", - "unavailableDescription": "GeoLibre kon de aanmeldservice niet bereiken en kan daarom niet vaststellen of u bent aangemeld. Dit is meestal tijdelijk; als het aanhoudt, vragen de authenticatie-instellingen van deze implementatie mogelijk om aandacht.", - "retry": "Opnieuw proberen", - "signInTitle": "Aanmelden bij GeoLibre", - "signInDescription": "Deze implementatie vereist een account. U wordt naar een beveiligde aanmeldpagina geleid en daarna hier teruggebracht.", - "signIn": "Aanmelden", - "signOut": "Afmelden", - "account": "Account" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index 347416711f..a5a2a78102 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -1624,6 +1624,16 @@ "exportCsv": "Exportar CSV", "exportGeoParquet": "Exportar GeoParquet" }, + "auth": { + "unavailableTitle": "O login não está disponível", + "unavailableDescription": "O GeoLibre não conseguiu acessar o serviço de login, portanto não sabe se você está conectado. Isso costuma ser temporário; se persistir, as configurações de autenticação desta implantação podem precisar de atenção.", + "retry": "Tentar novamente", + "signInTitle": "Faça login no GeoLibre", + "signInDescription": "Esta implantação requer uma conta. Você será levado a uma página de login segura e retornará para cá.", + "signIn": "Entrar", + "signOut": "Sair", + "account": "Conta" + }, "basemapExtract": { "title": "Extrair mapa base offline", "url": "URL do mapa base", @@ -4045,6 +4055,9 @@ "addStep": "Adicionar etapa", "runModel": "Executar o modelo", "deleteModel": "Excluir", + "canvas": "Tela de fluxo de trabalho espacial", + "importPipeline": "Importar pipeline", + "exportPipeline": "Exportar pipeline", "inputPreviousStep": "Entrada: ← saída da etapa anterior", "unknownTool": "Ferramenta desconhecida “{{id}}”", "noParameters": "Sem parâmetros." @@ -5585,15 +5598,5 @@ "mapPoint": "Ponto do mapa", "reply": "Responder", "replyPlaceholder": "Escreva uma resposta..." - }, - "auth": { - "unavailableTitle": "O login não está disponível", - "unavailableDescription": "O GeoLibre não conseguiu acessar o serviço de login, portanto não sabe se você está conectado. Isso costuma ser temporário; se persistir, as configurações de autenticação desta implantação podem precisar de atenção.", - "retry": "Tentar novamente", - "signInTitle": "Faça login no GeoLibre", - "signInDescription": "Esta implantação requer uma conta. Você será levado a uma página de login segura e retornará para cá.", - "signIn": "Entrar", - "signOut": "Sair", - "account": "Conta" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 6a6ed82722..76b0493e39 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -1710,6 +1710,16 @@ "exportCsv": "Экспорт CSV", "exportGeoParquet": "Экспорт GeoParquet" }, + "auth": { + "unavailableTitle": "Вход недоступен", + "unavailableDescription": "GeoLibre не удалось связаться со службой входа, поэтому невозможно определить, выполнен ли вход. Обычно это временно; если проблема сохраняется, возможно, требуется проверить настройки аутентификации этого развёртывания.", + "retry": "Повторить", + "signInTitle": "Вход в GeoLibre", + "signInDescription": "Для этого развёртывания требуется учётная запись. Вы будете перенаправлены на защищённую страницу входа и вернётесь сюда.", + "signIn": "Войти", + "signOut": "Выйти", + "account": "Учётная запись" + }, "basemapExtract": { "title": "Извлечь офлайн-базовую карту", "url": "URL базовой карты", @@ -4179,6 +4189,9 @@ "addStep": "Добавить шаг", "runModel": "Запустить модель", "deleteModel": "Удалить", + "canvas": "Холст пространственного рабочего процесса", + "importPipeline": "Импортировать конвейер", + "exportPipeline": "Экспортировать конвейер", "inputPreviousStep": "Вход: ← вывод предыдущего шага", "unknownTool": "Неизвестный инструмент «{{id}}»", "noParameters": "Нет параметров." @@ -5745,15 +5758,5 @@ "mapPoint": "Точка на карте", "reply": "Ответить", "replyPlaceholder": "Напишите ответ..." - }, - "auth": { - "unavailableTitle": "Вход недоступен", - "unavailableDescription": "GeoLibre не удалось связаться со службой входа, поэтому невозможно определить, выполнен ли вход. Обычно это временно; если проблема сохраняется, возможно, требуется проверить настройки аутентификации этого развёртывания.", - "retry": "Повторить", - "signInTitle": "Вход в GeoLibre", - "signInDescription": "Для этого развёртывания требуется учётная запись. Вы будете перенаправлены на защищённую страницу входа и вернётесь сюда.", - "signIn": "Войти", - "signOut": "Выйти", - "account": "Учётная запись" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index d693d191a8..153315bdcd 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -1581,6 +1581,16 @@ "exportCsv": "ส่งออก CSV", "exportGeoParquet": "ส่งออก GeoParquet" }, + "auth": { + "unavailableTitle": "ไม่สามารถลงชื่อเข้าใช้ได้", + "unavailableDescription": "GeoLibre ไม่สามารถเชื่อมต่อบริการลงชื่อเข้าใช้ได้ จึงไม่ทราบว่าคุณลงชื่อเข้าใช้อยู่หรือไม่ โดยทั่วไปปัญหานี้เกิดขึ้นชั่วคราว หากยังคงเกิดขึ้น อาจต้องตรวจสอบการตั้งค่าการยืนยันตัวตนของการติดตั้งใช้งานนี้", + "retry": "ลองอีกครั้ง", + "signInTitle": "ลงชื่อเข้าใช้ GeoLibre", + "signInDescription": "การติดตั้งใช้งานนี้ต้องมีบัญชี ระบบจะพาคุณไปยังหน้าลงชื่อเข้าใช้ที่ปลอดภัย แล้วกลับมาที่นี่", + "signIn": "ลงชื่อเข้าใช้", + "signOut": "ออกจากระบบ", + "account": "บัญชี" + }, "basemapExtract": { "title": "แยกแผนที่ฐานสำหรับใช้ออฟไลน์", "url": "URL ของแผนที่ฐาน", @@ -3978,6 +3988,9 @@ "addStep": "เพิ่มขั้นตอน", "runModel": "เรียกใช้โมเดล", "deleteModel": "ลบ", + "canvas": "พื้นที่ขั้นตอนการทำงานเชิงพื้นที่", + "importPipeline": "นำเข้าไปป์ไลน์", + "exportPipeline": "ส่งออกไปป์ไลน์", "inputPreviousStep": "ข้อมูลนำเข้า: ← ผลลัพธ์จากขั้นตอนก่อนหน้า", "unknownTool": "ไม่รู้จักเครื่องมือ \"{{id}}\"", "noParameters": "ไม่มีพารามิเตอร์" @@ -5505,15 +5518,5 @@ "mapPoint": "จุดบนแผนที่", "reply": "ตอบกลับ", "replyPlaceholder": "เขียนคำตอบ..." - }, - "auth": { - "unavailableTitle": "ไม่สามารถลงชื่อเข้าใช้ได้", - "unavailableDescription": "GeoLibre ไม่สามารถเชื่อมต่อบริการลงชื่อเข้าใช้ได้ จึงไม่ทราบว่าคุณลงชื่อเข้าใช้อยู่หรือไม่ โดยทั่วไปปัญหานี้เกิดขึ้นชั่วคราว หากยังคงเกิดขึ้น อาจต้องตรวจสอบการตั้งค่าการยืนยันตัวตนของการติดตั้งใช้งานนี้", - "retry": "ลองอีกครั้ง", - "signInTitle": "ลงชื่อเข้าใช้ GeoLibre", - "signInDescription": "การติดตั้งใช้งานนี้ต้องมีบัญชี ระบบจะพาคุณไปยังหน้าลงชื่อเข้าใช้ที่ปลอดภัย แล้วกลับมาที่นี่", - "signIn": "ลงชื่อเข้าใช้", - "signOut": "ออกจากระบบ", - "account": "บัญชี" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 5f30b51219..81722dde7b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -1624,6 +1624,16 @@ "exportCsv": "CSV Dışa Aktar", "exportGeoParquet": "GeoParquet Dışa Aktar" }, + "auth": { + "unavailableTitle": "Oturum açma kullanılamıyor", + "unavailableDescription": "GeoLibre oturum açma hizmetine ulaşamadı, bu nedenle oturum açıp açmadığınızı belirleyemiyor. Bu durum genellikle geçicidir; sürerse bu dağıtımın kimlik doğrulama ayarlarının gözden geçirilmesi gerekebilir.", + "retry": "Yeniden dene", + "signInTitle": "GeoLibre'de oturum açın", + "signInDescription": "Bu dağıtım bir hesap gerektirir. Güvenli bir oturum açma sayfasına yönlendirilecek ve ardından buraya geri getirileceksiniz.", + "signIn": "Oturum aç", + "signOut": "Oturumu kapat", + "account": "Hesap" + }, "basemapExtract": { "title": "Çevrimdışı Temel Harita Çıkar", "url": "Temel harita URL'si", @@ -4045,6 +4055,9 @@ "addStep": "Adım ekle", "runModel": "Modeli çalıştır", "deleteModel": "Sil", + "canvas": "Mekansal iş akışı tuvali", + "importPipeline": "İşlem hattını içe aktar", + "exportPipeline": "İşlem hattını dışa aktar", "inputPreviousStep": "Girdi: ← önceki adımın çıktısı", "unknownTool": "Bilinmeyen araç \"{{id}}\"", "noParameters": "Parametre yok." @@ -5585,15 +5598,5 @@ "mapPoint": "Harita Noktası", "reply": "Yanıtla", "replyPlaceholder": "Bir yanıt yazın..." - }, - "auth": { - "unavailableTitle": "Oturum açma kullanılamıyor", - "unavailableDescription": "GeoLibre oturum açma hizmetine ulaşamadı, bu nedenle oturum açıp açmadığınızı belirleyemiyor. Bu durum genellikle geçicidir; sürerse bu dağıtımın kimlik doğrulama ayarlarının gözden geçirilmesi gerekebilir.", - "retry": "Yeniden dene", - "signInTitle": "GeoLibre'de oturum açın", - "signInDescription": "Bu dağıtım bir hesap gerektirir. Güvenli bir oturum açma sayfasına yönlendirilecek ve ardından buraya geri getirileceksiniz.", - "signIn": "Oturum aç", - "signOut": "Oturumu kapat", - "account": "Hesap" } } diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index ee32eea947..cf8708feff 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -186,6 +186,81 @@ "heading": "tiêu đề", "finalHeading": "Tiêu đề cuối cùng" }, + "statusBar": { + "elevation": "Độ cao", + "elevationLong": "Độ cao của camera so với mực nước biển", + "cameraAltitude": "Độ cao camera", + "cameraAltitudeLong": "Độ cao của camera so với mực nước biển", + "coordinateFormat": { + "dd": "Độ thập phân", + "dms": "Độ, phút, giây", + "ddm": "Độ, phút thập phân", + "utm": "UTM (vùng, hướng đông/hướng bắc)" + }, + "coordinateFormatHint": "Định dạng tọa độ: {{format}}. Bấm để chuyển đổi." + }, + "fileNamePrompt": { + "title": "Lưu tập tin dưới dạng", + "description": "Nhập tên tập tin. Tệp tải xuống thư mục tải xuống của trình duyệt của bạn.", + "label": "Thêm một lớp từ Cơ sở dữ liệu địa lý tệp Esri (thư mục .gdb). Lớp này được đọc bằng máy chủ GeoLibre cục bộ và được chuyển hướng sang WGS84." + }, + "newProject": { + "doNotSave": "Không lưu", + "saving": "Đang lưu...", + "title": "Dự án mới", + "projectName": "Tên dự án", + "savedTemplates": "Mẫu đã lưu", + "deleteTemplate": "Xóa mẫu", + "basemapLabel": "Sơ đồ cơ sở", + "create": "Tạo nên", + "basemapDescription": "Chọn sơ đồ cơ sở OpenFreeMap hoặc Protomaps, nền trống hoặc URL kiểu MapLibre.", + "basemapDescriptionNoProtomaps": "Chọn lớp nền OpenFreeMap, nền trống hoặc URL kiểu MapLibre.", + "sectionOpenFreeMap": "Bản đồ mở miễn phí", + "sectionProtomaps": "Bản đồ nguyên mẫu", + "sectionOther": "Khác", + "customUrlButton": "URL tùy chỉnh", + "invalidCustomUrl": "Nhập URL style.json hoặc .pmtiles hợp lệ.", + "savePromptTitle": "Lưu dự án hiện tại?", + "savePromptDescription": "Dự án hiện tại có những thay đổi chưa được lưu. Lưu chúng trước khi tạo một dự án mới?" + }, + "basemapPicker": { + "invalidUrl": "Nhập URL kiểu HTTP hoặc HTTPS hợp lệ.", + "sectionMoon": "Mặt Trăng", + "sectionMars": "Sao Hỏa", + "sectionOther": "Các thiên thể khác", + "sectionRegional": "Khu vực", + "regionChina": "Trung Quốc (中国)", + "title": "Thay đổi bản đồ cơ sở", + "applyCustom": "Áp dụng" + }, + "planetSwitcher": { + "label": "Bản đồ nguyên mẫu", + "earth": "Trái đất", + "mercury": "Thủy ngân", + "venus": "sao Kim", + "moon": "Mặt trăng", + "mars": "Sao Hỏa", + "io": "Thêm một lớp từ Cơ sở dữ liệu địa lý tệp Esri (thư mục .gdb). Lớp này được đọc bằng máy chủ GeoLibre cục bộ và được chuyển hướng sang WGS84.", + "europa": "Bản đồ nguyên mẫu", + "ganymede": "Ganymede", + "callisto": "Callisto", + "titan": "Bắt đầu Martin cục bộ, khám phá các nguồn PostGIS và thêm nguồn dưới dạng các ô vector.", + "pluto": "Sao Diêm Vương", + "charon": "Charon" + }, + "common": { + "close": "Đóng", + "retry": "Thử lại", + "reset": "Đặt lại", + "remove": "Xóa", + "add": "Thêm", + "ok": "OK", + "done": "Xong", + "openWebApp": "Mở ứng dụng web GeoLibre", + "pickColorFromScreen": "Chọn màu từ màn hình", + "save": "Lưu", + "cancel": "Hủy" + }, "addData": { "kind": { "gdb": { @@ -707,80 +782,50 @@ }, "nonGeographicCoordinates": "Tọa độ trong {{names}} không phải là kinh độ/vĩ độ nên sẽ không có gì được vẽ trên bản đồ. CRS bị gắn nhãn sai — chiếu lại dữ liệu (hoặc sửa .prj của nó) và tải lại." }, - "common": { - "close": "Đóng", - "retry": "Thử lại", - "reset": "Đặt lại", - "remove": "Xóa", - "add": "Thêm", - "ok": "OK", - "done": "Xong", - "openWebApp": "Mở ứng dụng web GeoLibre", - "pickColorFromScreen": "Chọn màu từ màn hình", - "save": "Lưu", - "cancel": "Hủy" - }, - "statusBar": { - "elevation": "Độ cao", - "elevationLong": "Độ cao của camera so với mực nước biển", - "cameraAltitude": "Độ cao camera", - "cameraAltitudeLong": "Độ cao của camera so với mực nước biển", - "coordinateFormat": { - "dd": "Độ thập phân", - "dms": "Độ, phút, giây", - "ddm": "Độ, phút thập phân", - "utm": "UTM (vùng, hướng đông/hướng bắc)" - }, - "coordinateFormatHint": "Định dạng tọa độ: {{format}}. Bấm để chuyển đổi." - }, - "basemapPicker": { - "invalidUrl": "Nhập URL kiểu HTTP hoặc HTTPS hợp lệ.", - "sectionMoon": "Mặt Trăng", - "sectionMars": "Sao Hỏa", - "sectionOther": "Các thiên thể khác", - "sectionRegional": "Khu vực", - "regionChina": "Trung Quốc (中国)", - "title": "Thay đổi bản đồ cơ sở", - "applyCustom": "Áp dụng" - }, - "planetSwitcher": { - "label": "Bản đồ nguyên mẫu", - "earth": "Trái đất", - "mercury": "Thủy ngân", - "venus": "sao Kim", - "moon": "Mặt trăng", - "mars": "Sao Hỏa", - "io": "Thêm một lớp từ Cơ sở dữ liệu địa lý tệp Esri (thư mục .gdb). Lớp này được đọc bằng máy chủ GeoLibre cục bộ và được chuyển hướng sang WGS84.", - "europa": "Bản đồ nguyên mẫu", - "ganymede": "Ganymede", - "callisto": "Callisto", - "titan": "Bắt đầu Martin cục bộ, khám phá các nguồn PostGIS và thêm nguồn dưới dạng các ô vector.", - "pluto": "Sao Diêm Vương", - "charon": "Charon" - }, - "newProject": { - "doNotSave": "Không lưu", - "saving": "Đang lưu...", - "title": "Dự án mới", - "projectName": "Tên dự án", - "savedTemplates": "Mẫu đã lưu", - "deleteTemplate": "Xóa mẫu", - "basemapLabel": "Sơ đồ cơ sở", - "create": "Tạo nên", - "basemapDescription": "Chọn sơ đồ cơ sở OpenFreeMap hoặc Protomaps, nền trống hoặc URL kiểu MapLibre.", - "basemapDescriptionNoProtomaps": "Chọn lớp nền OpenFreeMap, nền trống hoặc URL kiểu MapLibre.", - "sectionOpenFreeMap": "Bản đồ mở miễn phí", - "sectionProtomaps": "Bản đồ nguyên mẫu", - "sectionOther": "Khác", - "customUrlButton": "URL tùy chỉnh", - "invalidCustomUrl": "Nhập URL style.json hoặc .pmtiles hợp lệ.", - "savePromptTitle": "Lưu dự án hiện tại?", - "savePromptDescription": "Dự án hiện tại có những thay đổi chưa được lưu. Lưu chúng trước khi tạo một dự án mới?" + "offline": { + "title": "Tải xuống khu vực ngoại tuyến", + "description": "Lưu các ô bản đồ cơ sở của chế độ xem bản đồ hiện tại để khu vực này tiếp tục hoạt động mà không cần kết nối mạng.", + "noServiceWorker": "Bộ nhớ đệm ngoại tuyến cần có ứng dụng web được cài đặt. Các ô bạn tải xuống ở đây sẽ không được lưu để sử dụng ngoại tuyến trong bản dựng này.", + "uncacheable": "Các nguồn bản đồ cơ sở sau đây không hỗ trợ tải xuống ngoại tuyến và sẽ bị thiếu trong bản đồ ngoại tuyến của bạn: {{hosts}}.", + "cacheable": "Các nguồn bản đồ cơ sở sau đây sẽ được tải xuống và khả dụng ngoại tuyến: {{hosts}}.", + "includeExtra": "Bao gồm các mức chi tiết bổ sung để phóng to", + "currentViewOnly": "Chỉ xem ở mức thu phóng hiện tại ({{zoom}}).", + "detailLevels": "Mức độ chi tiết bổ sung", + "completeWithFailures": "Đã lưu {{done}} trong số {{total}} tài nguyên ({{failed}} không thành công).", + "retryFailed_other": "Thử lại {{count}} tài nguyên bị lỗi", + "retryAll": "Thử lại tất cả tài nguyên", + "advanced": "Tùy chọn nâng cao", + "concurrency": "Yêu cầu đồng thời", + "concurrencyHint": "Giảm mức này xuống để giảm bớt các máy chủ có yêu cầu xếp ô nhanh giới hạn tốc độ.", + "timeout": "Yêu cầu hết thời gian (giây)", + "timeoutHint": "Mỗi yêu cầu ô được cung cấp tối đa {{seconds}} giây trước khi được tính là không thành công.", + "timeoutDisabled": "Thay thế các khung hình chính hiện tại bằng thiết lập đã tải?", + "relativeLevels_other": "+{{count}} mức · Thu phóng {{min}}–{{max}}", + "tiles": "Tài nguyên để tải về", + "tooManyTiles": "Khu vực này vượt quá giới hạn bộ nhớ đệm ngoại tuyến ({{max}} tài nguyên); các ô được lưu trong bộ nhớ đệm cũ nhất có thể bị xóa khi tải xuống tài nguyên mới hơn. Giảm mức độ chi tiết hoặc chọn một khu vực nhỏ hơn.", + "acknowledgeEviction": "Vẫn tải xuống, loại bỏ các ô cũ hơn nếu cần", + "download": "Tải xuống", + "cancel": "Hủy bỏ", + "progress": "Đã tải {{done}} / {{total}} ô.", + "complete_other": "Đã lưu khu vực ngoại tuyến. {{count}} tài nguyên được lưu vào bộ nhớ đệm để sử dụng ngoại tuyến." }, - "fileNamePrompt": { - "title": "Lưu tập tin dưới dạng", - "description": "Nhập tên tập tin. Tệp tải xuống thư mục tải xuống của trình duyệt của bạn.", - "label": "Thêm một lớp từ Cơ sở dữ liệu địa lý tệp Esri (thư mục .gdb). Lớp này được đọc bằng máy chủ GeoLibre cục bộ và được chuyển hướng sang WGS84." + "offlineManager": { + "update": "Làm mới khu vực này", + "delete": "Xóa khu vực này", + "confirmDelete": "Xóa bỏ", + "updateAll": "Làm mới tất cả", + "updating": "Đang làm mới {{done}} / {{total}}…", + "totalFootprint_other": "{{size}} trên {{count}} khu vực", + "deviceUsage": "{{usage}} trong số {{quota}} đã sử dụng", + "title": "Quản lý khu vực ngoại tuyến", + "description": "Xem lại các khu vực bản đồ cơ sở bạn đã tải xuống để sử dụng ngoại tuyến, làm mới hoặc xóa chúng để lấy lại bộ nhớ.", + "noServiceWorker": "Bộ nhớ đệm ngoại tuyến cần có ứng dụng web được cài đặt. Việc làm mới các khu vực sẽ không lưu ô để sử dụng ngoại tuyến trong bản dựng này.", + "empty": "Chưa có khu vực ngoại tuyến nào. Sử dụng \"Tải xuống khu vực ngoại tuyến\" để lưu chế độ xem bản đồ hiện tại để sử dụng ngoại tuyến.", + "zoomRange": "Thu phóng {{min}}–{{max}}", + "tilesCount_other": "{{count}} gạch", + "savedOn": "Đã lưu {{date}}", + "rename": "Đổi tên khu vực này", + "nameLabel": "Tên khu vực" }, "recordTour": { "unsupported": "Trình duyệt này không thể ghi lại canvas bản đồ. Hãy thử phiên bản Chrome, Edge hoặc Firefox gần đây.", @@ -838,51 +883,6 @@ "moveDown": "Di chuyển khung hình chính xuống", "removeKeyframe": "Xóa khung hình chính" }, - "offlineManager": { - "update": "Làm mới khu vực này", - "delete": "Xóa khu vực này", - "confirmDelete": "Xóa bỏ", - "updateAll": "Làm mới tất cả", - "updating": "Đang làm mới {{done}} / {{total}}…", - "totalFootprint_other": "{{size}} trên {{count}} khu vực", - "deviceUsage": "{{usage}} trong số {{quota}} đã sử dụng", - "title": "Quản lý khu vực ngoại tuyến", - "description": "Xem lại các khu vực bản đồ cơ sở bạn đã tải xuống để sử dụng ngoại tuyến, làm mới hoặc xóa chúng để lấy lại bộ nhớ.", - "noServiceWorker": "Bộ nhớ đệm ngoại tuyến cần có ứng dụng web được cài đặt. Việc làm mới các khu vực sẽ không lưu ô để sử dụng ngoại tuyến trong bản dựng này.", - "empty": "Chưa có khu vực ngoại tuyến nào. Sử dụng \"Tải xuống khu vực ngoại tuyến\" để lưu chế độ xem bản đồ hiện tại để sử dụng ngoại tuyến.", - "zoomRange": "Thu phóng {{min}}–{{max}}", - "tilesCount_other": "{{count}} gạch", - "savedOn": "Đã lưu {{date}}", - "rename": "Đổi tên khu vực này", - "nameLabel": "Tên khu vực" - }, - "offline": { - "title": "Tải xuống khu vực ngoại tuyến", - "description": "Lưu các ô bản đồ cơ sở của chế độ xem bản đồ hiện tại để khu vực này tiếp tục hoạt động mà không cần kết nối mạng.", - "noServiceWorker": "Bộ nhớ đệm ngoại tuyến cần có ứng dụng web được cài đặt. Các ô bạn tải xuống ở đây sẽ không được lưu để sử dụng ngoại tuyến trong bản dựng này.", - "uncacheable": "Các nguồn bản đồ cơ sở sau đây không hỗ trợ tải xuống ngoại tuyến và sẽ bị thiếu trong bản đồ ngoại tuyến của bạn: {{hosts}}.", - "cacheable": "Các nguồn bản đồ cơ sở sau đây sẽ được tải xuống và khả dụng ngoại tuyến: {{hosts}}.", - "includeExtra": "Bao gồm các mức chi tiết bổ sung để phóng to", - "currentViewOnly": "Chỉ xem ở mức thu phóng hiện tại ({{zoom}}).", - "detailLevels": "Mức độ chi tiết bổ sung", - "completeWithFailures": "Đã lưu {{done}} trong số {{total}} tài nguyên ({{failed}} không thành công).", - "retryFailed_other": "Thử lại {{count}} tài nguyên bị lỗi", - "retryAll": "Thử lại tất cả tài nguyên", - "advanced": "Tùy chọn nâng cao", - "concurrency": "Yêu cầu đồng thời", - "concurrencyHint": "Giảm mức này xuống để giảm bớt các máy chủ có yêu cầu xếp ô nhanh giới hạn tốc độ.", - "timeout": "Yêu cầu hết thời gian (giây)", - "timeoutHint": "Mỗi yêu cầu ô được cung cấp tối đa {{seconds}} giây trước khi được tính là không thành công.", - "timeoutDisabled": "Thay thế các khung hình chính hiện tại bằng thiết lập đã tải?", - "relativeLevels_other": "+{{count}} mức · Thu phóng {{min}}–{{max}}", - "tiles": "Tài nguyên để tải về", - "tooManyTiles": "Khu vực này vượt quá giới hạn bộ nhớ đệm ngoại tuyến ({{max}} tài nguyên); các ô được lưu trong bộ nhớ đệm cũ nhất có thể bị xóa khi tải xuống tài nguyên mới hơn. Giảm mức độ chi tiết hoặc chọn một khu vực nhỏ hơn.", - "acknowledgeEviction": "Vẫn tải xuống, loại bỏ các ô cũ hơn nếu cần", - "download": "Tải xuống", - "cancel": "Hủy bỏ", - "progress": "Đã tải {{done}} / {{total}} ô.", - "complete_other": "Đã lưu khu vực ngoại tuyến. {{count}} tài nguyên được lưu vào bộ nhớ đệm để sử dụng ngoại tuyến." - }, "recordVideo": { "preparing": "Chuẩn bị…", "stop": "Dừng lại", @@ -1263,6 +1263,18 @@ "errorNotConfigured": "Triển khai này không có máy chủ chia sẻ dự án nào được cấu hình.", "retry": "Thử lại" }, + "template": { + "saveTitle": "Lưu dưới dạng mẫu", + "saveDescription": "Lưu dự án được định cấu hình trước này dưới dạng mẫu có thể sử dụng lại trong thư viện cá nhân của bạn.", + "defaultName": "Mẫu không có tiêu đề", + "nameLabel": "Tên mẫu", + "namePlaceholder": "ví dụ. Sơ đồ tổ chức tiêu chuẩn", + "descriptionLabel": "Mô tả (tùy chọn)", + "descriptionPlaceholder": "ví dụ. Sơ đồ cơ sở, kiểu và bố cục trang tổng quan được định cấu hình trước cho bản đồ nhóm", + "stripDataLayersLabel": "Tách lớp dữ liệu", + "stripDataLayersDesc": "Giữ lại sơ đồ cơ sở, nhóm lớp, kiểu, chú giải, tiện ích và bố cục nhưng xóa nội dung lớp dữ liệu.", + "saveAction": "Lưu vào Thư viện mẫu" + }, "language": { "label": "Ngôn ngữ", "description": "Chọn ngôn ngữ sử dụng cho giao diện." @@ -1487,18 +1499,6 @@ "hint": "Kéo một hộp trên bản đồ để đặt vùng in chính xác. Giữ phím Shift để khớp với tỷ lệ trang." } }, - "template": { - "saveTitle": "Lưu dưới dạng mẫu", - "saveDescription": "Lưu dự án được định cấu hình trước này dưới dạng mẫu có thể sử dụng lại trong thư viện cá nhân của bạn.", - "defaultName": "Mẫu không có tiêu đề", - "nameLabel": "Tên mẫu", - "namePlaceholder": "ví dụ. Sơ đồ tổ chức tiêu chuẩn", - "descriptionLabel": "Mô tả (tùy chọn)", - "descriptionPlaceholder": "ví dụ. Sơ đồ cơ sở, kiểu và bố cục trang tổng quan được định cấu hình trước cho bản đồ nhóm", - "stripDataLayersLabel": "Tách lớp dữ liệu", - "stripDataLayersDesc": "Giữ lại sơ đồ cơ sở, nhóm lớp, kiểu, chú giải, tiện ích và bố cục nhưng xóa nội dung lớp dữ liệu.", - "saveAction": "Lưu vào Thư viện mẫu" - }, "kml": { "importFailed": "Không thể nhập KML/KMZ.", "timeOverlayGroup": "Hoạt hình lớp phủ thời gian" @@ -1535,6 +1535,62 @@ "start": "Chuỗi thời gian pixel" } }, + "terrainSettings": { + "title": "Phóng đại địa hình", + "description": "Điều chỉnh mức phóng đại theo chiều dọc của địa hình 3D. Giá trị cao hơn làm cho đồi và núi có vẻ dốc hơn.", + "label": "Phóng đại theo chiều dọc", + "controlLabel": "Chuyển đổi địa hình (nhấp đúp để phóng đại)", + "sourceLabel": "Nguồn địa hình", + "sourceDescription": "Dùng một COG đơn dải theo EPSG:3857 hoặc EPSG:4326 làm nguồn độ cao. Tệp phải cho phép CORS và các yêu cầu phạm vi HTTP.", + "sourcePlaceholder": "https://example.com/dem.tif", + "localSourceLabel": "COG DEM cục bộ", + "localSourceDescription": "Tệp vẫn nằm trên thiết bị này và được đọc theo từng phạm vi nhỏ khi cần các ô địa hình.", + "useCog": "Dùng COG DEM", + "sourceLoading": "Đang mở COG…", + "restoreDefaultSource": "Dùng địa hình toàn cầu", + "sourceError": "Không thể mở COG DEM.", + "sourceErrorEmpty": "Tệp này trống. Hãy chọn một tệp COG DEM.", + "sourceErrorBand": "Dải được yêu cầu không tồn tại trong COG này.", + "sourceErrorProjection": "Địa hình COG chỉ hỗ trợ DEM theo EPSG:3857 và EPSG:4326.", + "sourceErrorDetail": "Không thể mở COG DEM. ({{detail}})", + "reset": "Cài lại", + "done": "Xong" + }, + "pixelTimeSeries": { + "title": "Chuỗi thời gian pixel", + "empty": "Nhấp vào một pixel trên bản đồ để vẽ giá trị của nó theo thời gian. Thêm nhiều pixel hơn để so sánh.", + "band": "Ban nhạc", + "bandOption": "Ban nhạc {{index}}", + "pointLabel": "Điểm {{number}}", + "chartAria": "Thử lại", + "yAxis": "Trục Y", + "yMin": "tối thiểu", + "yMax": "Tối đa", + "axisAuto": "tự động", + "gapNote": "Thay đổi kích thước bảng điều khiển", + "exportCsv": "Xuất CSV", + "exportGeoParquet": "Xuất khẩu sàn gỗ địa lý", + "pickPoints": "Chọn điểm", + "stopPicking": "Dừng chọn", + "clearAll": "Xóa tất cả", + "removePoint": "Xóa điểm tại {{label}}?", + "close": "Đặt làm bản đồ cơ sở nhưng không lưu được vào đĩa: __PH0__", + "erroredExcluded": "Xuất không bao gồm {{n}} lần đọc không thành công.", + "querying": "Đọc giá trị pixel trên dòng thời gian...", + "progress": "Đang đọc {{done}} của {{total}}...", + "noValues": "Không có giá trị nào cho dải đã chọn tại các vị trí này (bên ngoài raster hoặc tất cả các dấu thời gian đều là nốt).", + "truncated": "Hiển thị {{kept}} trong số {{total}} bước tiến trình (được lấy mẫu xuống để giới hạn số lần đọc)." + }, + "auth": { + "retry": "Thử lại", + "signInTitle": "Đăng nhập vào GeoLibre", + "signInDescription": "Việc triển khai này yêu cầu phải có tài khoản. Bạn sẽ được đưa đến trang đăng nhập an toàn và quay lại đây.", + "signIn": "Đăng nhập", + "signOut": "Đăng xuất", + "account": "Tài khoản", + "unavailableTitle": "Đăng nhập không khả dụng", + "unavailableDescription": "GeoLibre không thể truy cập dịch vụ đăng nhập nên không thể biết bạn đã đăng nhập hay chưa. Việc này thường là tạm thời; nếu tình trạng này vẫn tiếp diễn thì có thể cần chú ý đến cài đặt xác thực của triển khai này." + }, "basemapExtract": { "confirmDelete": "Xóa bỏ?", "style": "Kiểu bản đồ cơ sở", @@ -1588,37 +1644,6 @@ "rename": "Đổi tên", "delete": "Xóa bỏ" }, - "auth": { - "retry": "Thử lại", - "signInTitle": "Đăng nhập vào GeoLibre", - "signInDescription": "Việc triển khai này yêu cầu phải có tài khoản. Bạn sẽ được đưa đến trang đăng nhập an toàn và quay lại đây.", - "signIn": "Đăng nhập", - "signOut": "Đăng xuất", - "account": "Tài khoản", - "unavailableTitle": "Đăng nhập không khả dụng", - "unavailableDescription": "GeoLibre không thể truy cập dịch vụ đăng nhập nên không thể biết bạn đã đăng nhập hay chưa. Việc này thường là tạm thời; nếu tình trạng này vẫn tiếp diễn thì có thể cần chú ý đến cài đặt xác thực của triển khai này." - }, - "terrainSettings": { - "title": "Phóng đại địa hình", - "description": "Điều chỉnh mức phóng đại theo chiều dọc của địa hình 3D. Giá trị cao hơn làm cho đồi và núi có vẻ dốc hơn.", - "label": "Phóng đại theo chiều dọc", - "controlLabel": "Chuyển đổi địa hình (nhấp đúp để phóng đại)", - "sourceLabel": "Nguồn địa hình", - "sourceDescription": "Dùng một COG đơn dải theo EPSG:3857 hoặc EPSG:4326 làm nguồn độ cao. Tệp phải cho phép CORS và các yêu cầu phạm vi HTTP.", - "sourcePlaceholder": "https://example.com/dem.tif", - "localSourceLabel": "COG DEM cục bộ", - "localSourceDescription": "Tệp vẫn nằm trên thiết bị này và được đọc theo từng phạm vi nhỏ khi cần các ô địa hình.", - "useCog": "Dùng COG DEM", - "sourceLoading": "Đang mở COG…", - "restoreDefaultSource": "Dùng địa hình toàn cầu", - "sourceError": "Không thể mở COG DEM.", - "sourceErrorEmpty": "Tệp này trống. Hãy chọn một tệp COG DEM.", - "sourceErrorBand": "Dải được yêu cầu không tồn tại trong COG này.", - "sourceErrorProjection": "Địa hình COG chỉ hỗ trợ DEM theo EPSG:3857 và EPSG:4326.", - "sourceErrorDetail": "Không thể mở COG DEM. ({{detail}})", - "reset": "Cài lại", - "done": "Xong" - }, "rasterSubset": { "title": "Trích xuất tập hợp con", "drawBbox": "Vẽ khung giới hạn", @@ -1652,31 +1677,6 @@ "additionalArgsHint": "Tự nhiên", "errorResolution": "Độ phân giải phải là số dương." }, - "pixelTimeSeries": { - "title": "Chuỗi thời gian pixel", - "empty": "Nhấp vào một pixel trên bản đồ để vẽ giá trị của nó theo thời gian. Thêm nhiều pixel hơn để so sánh.", - "band": "Ban nhạc", - "bandOption": "Ban nhạc {{index}}", - "pointLabel": "Điểm {{number}}", - "chartAria": "Thử lại", - "yAxis": "Trục Y", - "yMin": "tối thiểu", - "yMax": "Tối đa", - "axisAuto": "tự động", - "gapNote": "Thay đổi kích thước bảng điều khiển", - "exportCsv": "Xuất CSV", - "exportGeoParquet": "Xuất khẩu sàn gỗ địa lý", - "pickPoints": "Chọn điểm", - "stopPicking": "Dừng chọn", - "clearAll": "Xóa tất cả", - "removePoint": "Xóa điểm tại {{label}}?", - "close": "Đặt làm bản đồ cơ sở nhưng không lưu được vào đĩa: __PH0__", - "erroredExcluded": "Xuất không bao gồm {{n}} lần đọc không thành công.", - "querying": "Đọc giá trị pixel trên dòng thời gian...", - "progress": "Đang đọc {{done}} của {{total}}...", - "noValues": "Không có giá trị nào cho dải đã chọn tại các vị trí này (bên ngoài raster hoặc tất cả các dấu thời gian đều là nốt).", - "truncated": "Hiển thị {{kept}} trong số {{total}} bước tiến trình (được lấy mẫu xuống để giới hạn số lần đọc)." - }, "mapGrid": { "layers": "Lớp", "layersLabel": "Khả năng hiển thị lớp cho bản đồ {{number}}", @@ -1688,6 +1688,16 @@ "show2d": "Hiển thị bản đồ {{number}} dưới dạng bản đồ 2D", "only2d": "chỉ 2D" }, + "mapContextMenu": { + "zoomInHere": "Phóng to ở đây", + "viewInGoogleMaps": "Xem trên Google Maps", + "viewInGoogleEarth": "Xem trong Google Earth", + "copyCoordinatesHint": "Sao chép tọa độ vào clipboard", + "quickActions": "Hành động nhanh", + "whatsHere": "Ở đây có gì thế?", + "copyGeoJson": "Sao chép dưới dạng GeoJSON", + "centerHere": "Bản đồ trung tâm đây" + }, "quickAnalysis": { "failed": "{{tool}} không thành công", "viewDetails": "Xem chi tiết", @@ -1719,6 +1729,36 @@ "boundingBoxLayerName": "{{name}} hộp giới hạn", "running": "Đang chạy {{tool}}…" }, + "knowledgeCard": { + "title": "Thông tin địa điểm", + "loading": "Đang tìm kiếm nơi này…", + "empty": "Không tìm thấy bài viết Wikipedia nào gần địa điểm này.", + "error": "Không thể tải thông tin địa điểm. Vui lòng thử lại.", + "readMore": "chỉ 2D", + "nearby": "Địa điểm lân cận", + "attribution": "Nội dung từ Wikipedia, được cấp phép CC BY-SA.", + "noticeTitle": "Thẻ kiến ​​thức sử dụng Wikipedia", + "noticeDesc": "Việc mở thẻ kiến ​​thức sẽ gửi tọa độ của địa điểm bạn đã nhấp vào (và tiêu đề của bất kỳ bài viết nào gần đó mà bạn mở) tới API công khai của Wikipedia để tìm nạp bản tóm tắt và ảnh — tọa độ của bạn sẽ để lại thiết bị của bạn cho những yêu cầu đó." + }, + "onboarding": { + "description": "Lưu cài đặt", + "level": { + "beginner": { + "title": "Người mới bắt đầu", + "description": "Chỉ hiển thị các nguồn dữ liệu và công cụ cần thiết." + }, + "intermediate": { + "title": "Phía đông", + "description": "Hiển thị các nguồn dữ liệu, dịch vụ và plugin phổ biến." + }, + "advanced": { + "title": "chỉ 2D", + "description": "Hiển thị mọi thứ GeoLibre cung cấp." + } + }, + "showEverything": "Bỏ qua - hiển thị mọi thứ", + "title": "Chào mừng đến với GeoLibre" + }, "settings": { "saveButton": "Lưu cài đặt", "section": { @@ -1961,46 +2001,6 @@ "corsNote": "Google không chính thức cho phép các yêu cầu của trình duyệt đối với API mã hóa địa lý của mình, do đó, các yêu cầu có thể bị chặn trừ khi được cung cấp qua proxy có cùng nguồn gốc." } }, - "onboarding": { - "description": "Lưu cài đặt", - "level": { - "beginner": { - "title": "Người mới bắt đầu", - "description": "Chỉ hiển thị các nguồn dữ liệu và công cụ cần thiết." - }, - "intermediate": { - "title": "Phía đông", - "description": "Hiển thị các nguồn dữ liệu, dịch vụ và plugin phổ biến." - }, - "advanced": { - "title": "chỉ 2D", - "description": "Hiển thị mọi thứ GeoLibre cung cấp." - } - }, - "showEverything": "Bỏ qua - hiển thị mọi thứ", - "title": "Chào mừng đến với GeoLibre" - }, - "mapContextMenu": { - "zoomInHere": "Phóng to ở đây", - "viewInGoogleMaps": "Xem trên Google Maps", - "viewInGoogleEarth": "Xem trong Google Earth", - "copyCoordinatesHint": "Sao chép tọa độ vào clipboard", - "quickActions": "Hành động nhanh", - "whatsHere": "Ở đây có gì thế?", - "copyGeoJson": "Sao chép dưới dạng GeoJSON", - "centerHere": "Bản đồ trung tâm đây" - }, - "knowledgeCard": { - "title": "Thông tin địa điểm", - "loading": "Đang tìm kiếm nơi này…", - "empty": "Không tìm thấy bài viết Wikipedia nào gần địa điểm này.", - "error": "Không thể tải thông tin địa điểm. Vui lòng thử lại.", - "readMore": "chỉ 2D", - "nearby": "Địa điểm lân cận", - "attribution": "Nội dung từ Wikipedia, được cấp phép CC BY-SA.", - "noticeTitle": "Thẻ kiến ​​thức sử dụng Wikipedia", - "noticeDesc": "Việc mở thẻ kiến ​​thức sẽ gửi tọa độ của địa điểm bạn đã nhấp vào (và tiêu đề của bất kỳ bài viết nào gần đó mà bạn mở) tới API công khai của Wikipedia để tìm nạp bản tóm tắt và ảnh — tọa độ của bạn sẽ để lại thiết bị của bạn cho những yêu cầu đó." - }, "toolbar": { "menu": { "project": "Dự án", @@ -2901,39 +2901,6 @@ "confirmExisting": "Trình chỉnh sửa đã có {{count}} tính năng. Nối thêm chúng hay loại bỏ và thay thế?", "tooMany": "Việc tải các tính năng {{count}} có thể khiến trình soạn thảo phản hồi chậm. Vẫn tải chúng?" }, - "h3Plugin": { - "fillColor": "Tô màu", - "fillOpacity": "Điền vào độ mờ", - "lineColor": "Màu phác thảo", - "lineWidth": "chiều rộng phác thảo", - "showLabels": "Hiển thị ID ô", - "identifyHint": "Nhấp vào bản đồ để xác định ô H3.", - "selectedCell": "Ô đã chọn", - "noSelection": "Không có ô nào được chọn", - "copyId": "Sao chép giấy tờ tùy thân", - "copied": "Đã sao chép", - "title": "Lưới H3", - "controlTitle": "Cài đặt lưới H3", - "autoResolution": "Độ phân giải tự động", - "resolution": "Nghị quyết", - "cellCount": "{{count}} ô H3", - "tooManyCells": "Chế độ xem này vượt quá giới hạn ô {{limit}}. Phóng to hoặc giảm độ phân giải.", - "parent": "Cha mẹ)", - "children": "Những đứa trẻ", - "neighbors": "Bao gồm các ô lân cận đã chọn", - "baseCell": "Tế bào cơ sở", - "center": "Trung tâm", - "pentagon": "Lầu Năm Góc", - "yes": "Đúng", - "no": "KHÔNG", - "zoomToCell": "Thu phóng đến ô", - "addAsLayer": "Thêm lưới dưới dạng lớp", - "exportGeoJson": "Xuất GeoJSON", - "exportCsv": "Xuất CSV", - "includeNeighbors": "Bao gồm các ô lân cận đã chọn", - "includeParents": "Bao gồm (các) ô cha đã chọn", - "showIcosahedron": "Hiển thị khối hai mươi mặt" - }, "geocode": { "reverseFailed": "Tô màu", "apiKeyRequired": "{{provider}} cần khóa API. Thêm một trong Cài đặt để nhận kết quả.", @@ -2989,38 +2956,38 @@ "formatDms": "Độ/phút/giây", "labelEdges": "Nhãn cạnh" }, - "dggridPlugin": { - "topologyTriangle": "chiều rộng phác thảo", - "projection": "Chiếu", - "aperture": "Khẩu độ", - "autoResolution": "Độ phân giải tự động", - "resolution": "Nghị quyết", - "cellCount": "{{count}} ô đang xem", - "tooManyCells": "Chế độ xem này vượt quá giới hạn ô {{limit}}. Phóng to hoặc giảm độ phân giải.", - "fillColor": "Sao chép giấy tờ tùy thân", + "h3Plugin": { + "fillColor": "Tô màu", "fillOpacity": "Điền vào độ mờ", "lineColor": "Màu phác thảo", - "zoomToCell": "Thu phóng đến ô", - "addAsLayer": "Thêm lưới dưới dạng lớp", - "exportGeoJson": "Xuất GeoJSON", - "exportCsv": "Xuất CSV", - "includeNeighbors": "Bao gồm các ô lân cận đã chọn", - "includeParents": "Bao gồm (các) ô cha đã chọn", - "title": "DGGRID", - "controlTitle": "Cài đặt DGGRID", - "cellType": "Loại tế bào", - "topologyHexagon": "lục giác", - "topologyDiamond": "Kim cương", "lineWidth": "chiều rộng phác thảo", "showLabels": "Hiển thị ID ô", - "identifyHint": "Nhấp vào bản đồ để xác định ô DGGRID.", + "identifyHint": "Nhấp vào bản đồ để xác định ô H3.", "selectedCell": "Ô đã chọn", "noSelection": "Không có ô nào được chọn", "copyId": "Sao chép giấy tờ tùy thân", + "copied": "Đã sao chép", + "title": "Lưới H3", + "controlTitle": "Cài đặt lưới H3", + "autoResolution": "Độ phân giải tự động", + "resolution": "Nghị quyết", + "cellCount": "{{count}} ô H3", + "tooManyCells": "Chế độ xem này vượt quá giới hạn ô {{limit}}. Phóng to hoặc giảm độ phân giải.", "parent": "Cha mẹ)", "children": "Những đứa trẻ", - "neighbors": "hàng xóm", - "center": "Trung tâm" + "neighbors": "Bao gồm các ô lân cận đã chọn", + "baseCell": "Tế bào cơ sở", + "center": "Trung tâm", + "pentagon": "Lầu Năm Góc", + "yes": "Đúng", + "no": "KHÔNG", + "zoomToCell": "Thu phóng đến ô", + "addAsLayer": "Thêm lưới dưới dạng lớp", + "exportGeoJson": "Xuất GeoJSON", + "exportCsv": "Xuất CSV", + "includeNeighbors": "Bao gồm các ô lân cận đã chọn", + "includeParents": "Bao gồm (các) ô cha đã chọn", + "showIcosahedron": "Hiển thị khối hai mươi mặt" }, "s2Plugin": { "parent": "Cha mẹ)", @@ -3076,6 +3043,39 @@ "lineColor": "Màu phác thảo", "lineWidth": "chiều rộng phác thảo" }, + "dggridPlugin": { + "topologyTriangle": "chiều rộng phác thảo", + "projection": "Chiếu", + "aperture": "Khẩu độ", + "autoResolution": "Độ phân giải tự động", + "resolution": "Nghị quyết", + "cellCount": "{{count}} ô đang xem", + "tooManyCells": "Chế độ xem này vượt quá giới hạn ô {{limit}}. Phóng to hoặc giảm độ phân giải.", + "fillColor": "Sao chép giấy tờ tùy thân", + "fillOpacity": "Điền vào độ mờ", + "lineColor": "Màu phác thảo", + "zoomToCell": "Thu phóng đến ô", + "addAsLayer": "Thêm lưới dưới dạng lớp", + "exportGeoJson": "Xuất GeoJSON", + "exportCsv": "Xuất CSV", + "includeNeighbors": "Bao gồm các ô lân cận đã chọn", + "includeParents": "Bao gồm (các) ô cha đã chọn", + "title": "DGGRID", + "controlTitle": "Cài đặt DGGRID", + "cellType": "Loại tế bào", + "topologyHexagon": "lục giác", + "topologyDiamond": "Kim cương", + "lineWidth": "chiều rộng phác thảo", + "showLabels": "Hiển thị ID ô", + "identifyHint": "Nhấp vào bản đồ để xác định ô DGGRID.", + "selectedCell": "Ô đã chọn", + "noSelection": "Không có ô nào được chọn", + "copyId": "Sao chép giấy tờ tùy thân", + "parent": "Cha mẹ)", + "children": "Những đứa trẻ", + "neighbors": "hàng xóm", + "center": "Trung tâm" + }, "dggalPlugin": { "title": "DGGAL", "controlTitle": "Cài đặt DGGAL", @@ -3204,6 +3204,19 @@ "recordingUnsupported": "Ghi canvas không được hỗ trợ trong trình duyệt này.", "loadingTiles": "Đang tải ô…" }, + "mapillary": { + "tokenLabel": "Mã thông báo truy cập bản đồ", + "loading": "Đang tải hình ảnh…", + "loadError": "Không thể tải hình ảnh này.", + "coverageLines": "Trình tự bản đồ", + "coveragePoints": "Hình ảnh bản đồ", + "title": "bản đồ", + "hint": "Nhấp vào một điểm phủ sóng trên bản đồ để xem hình ảnh ở cấp độ đường phố.", + "noToken": "Cần có mã thông báo truy cập Mapillary để tải vùng phủ sóng và hình ảnh. Dán một cái bên dưới để bắt đầu.", + "tokenPlaceholder": "MLY|…", + "tokenSave": "Lưu mã thông báo", + "tokenHelp": "Nhận mã thông báo" + }, "openAerialMap": { "close": "Đóng", "metaTitle": "Tiêu đề", @@ -3276,19 +3289,6 @@ "downloading": "Đang tải xuống {{title}}: {{completed}} trong số {{total}} tính năng…", "downloadStarted": "Đã bắt đầu tải xuống cho {{title}}." }, - "mapillary": { - "tokenLabel": "Mã thông báo truy cập bản đồ", - "loading": "Đang tải hình ảnh…", - "loadError": "Không thể tải hình ảnh này.", - "coverageLines": "Trình tự bản đồ", - "coveragePoints": "Hình ảnh bản đồ", - "title": "bản đồ", - "hint": "Nhấp vào một điểm phủ sóng trên bản đồ để xem hình ảnh ở cấp độ đường phố.", - "noToken": "Cần có mã thông báo truy cập Mapillary để tải vùng phủ sóng và hình ảnh. Dán một cái bên dưới để bắt đầu.", - "tokenPlaceholder": "MLY|…", - "tokenSave": "Lưu mã thông báo", - "tokenHelp": "Nhận mã thông báo" - }, "openDataCatalogs": { "socrataHint": "Tìm kiếm các danh mục dữ liệu mở Socrata công khai và thêm bộ dữ liệu GeoJSON.", "ckanHint": "Tìm kiếm danh mục CKAN của Sàn giao dịch dữ liệu nhân đạo và thêm các tài nguyên GeoJSON có sẵn.", @@ -3638,6 +3638,43 @@ }, "deleteLast": "Xóa chú thích cuối cùng" }, + "pythonConsole": { + "runScript": "__PH0__ tiện ích", + "untitled": "untitled.py", + "discardChanges": "Hủy các thay đổi chưa được lưu đối với tập lệnh hiện tại?", + "editorPlaceholder": "Viết tập lệnh Python nhiều dòng tại đây. Ctrl/Cmd+Enter chạy nó (hoặc vùng chọn) trong bảng điều khiển; Thụt lề tab; Ctrl+Space tự động hoàn thành.", + "title": "Bảng điều khiển Python", + "resize": "Thay đổi kích thước bảng điều khiển Python", + "clear": "Xóa đầu ra", + "collapse": "Thu gọn bảng điều khiển Python", + "expand": "Mở rộng bảng điều khiển Python", + "close": "Đóng bảng điều khiển Python", + "run": "Chạy", + "runHint": "Chạy (Ctrl/Cmd+Enter)", + "completions": "Hoàn thành", + "placeholder": "Python ở đây · Ctrl/Cmd+Enter để chạy · Tab để tự động hoàn thành · ↑/↓ cho lịch sử", + "intro": "Bảng điều khiển Python đã sẵn sàng. Sử dụng đối tượng `geolibre` để viết kịch bản cho bản đồ, ví dụ: geolibre.get_center(). Tab tự động hoàn thành, ↑/↓ gọi lại các lệnh trước đó và `await geolibre.load_package(\"numpy\")` thêm các gói.", + "loadFailed": "Không tải được thời gian chạy Python.", + "showEditor": "Ghi chú nội dung", + "hideEditor": "Thay đổi kích thước bảng sổ tay", + "resizeEditor": "Thay đổi kích thước trình chỉnh sửa", + "fileNew": "Kịch bản mới", + "fileOpen": "Mở tập lệnh…", + "fileSave": "Lưu tập lệnh (Ctrl/Cmd+S)", + "fileSaveAs": "Lưu tập lệnh dưới dạng…", + "clearEditor": "Xóa trình chỉnh sửa" + }, + "notebook": { + "serverUrlCopied": "Đã sao chép URL máy chủ", + "loading": "Đang tải sổ tay…", + "loadFailed": "Không tải được sổ ghi chép.", + "title": "Sổ tay", + "resize": "Thay đổi kích thước bảng sổ tay", + "collapse": "Thu gọn sổ tay", + "expand": "Mở rộng sổ ghi chép", + "close": "Đóng sổ ghi chép", + "copyServerUrl": "Sao chép URL máy chủ cho máy khách bên ngoài (VS Code…)" + }, "dashboard": { "chartType": { "indicator": "Tối đa", @@ -3723,42 +3760,31 @@ "save": "Cứu" } }, - "notebook": { - "serverUrlCopied": "Đã sao chép URL máy chủ", - "loading": "Đang tải sổ tay…", - "loadFailed": "Không tải được sổ ghi chép.", - "title": "Sổ tay", - "resize": "Thay đổi kích thước bảng sổ tay", - "collapse": "Thu gọn sổ tay", - "expand": "Mở rộng sổ ghi chép", - "close": "Đóng sổ ghi chép", - "copyServerUrl": "Sao chép URL máy chủ cho máy khách bên ngoài (VS Code…)" - }, - "pythonConsole": { - "runScript": "__PH0__ tiện ích", - "untitled": "untitled.py", - "discardChanges": "Hủy các thay đổi chưa được lưu đối với tập lệnh hiện tại?", - "editorPlaceholder": "Viết tập lệnh Python nhiều dòng tại đây. Ctrl/Cmd+Enter chạy nó (hoặc vùng chọn) trong bảng điều khiển; Thụt lề tab; Ctrl+Space tự động hoàn thành.", - "title": "Bảng điều khiển Python", - "resize": "Thay đổi kích thước bảng điều khiển Python", - "clear": "Xóa đầu ra", - "collapse": "Thu gọn bảng điều khiển Python", - "expand": "Mở rộng bảng điều khiển Python", - "close": "Đóng bảng điều khiển Python", - "run": "Chạy", - "runHint": "Chạy (Ctrl/Cmd+Enter)", - "completions": "Hoàn thành", - "placeholder": "Python ở đây · Ctrl/Cmd+Enter để chạy · Tab để tự động hoàn thành · ↑/↓ cho lịch sử", - "intro": "Bảng điều khiển Python đã sẵn sàng. Sử dụng đối tượng `geolibre` để viết kịch bản cho bản đồ, ví dụ: geolibre.get_center(). Tab tự động hoàn thành, ↑/↓ gọi lại các lệnh trước đó và `await geolibre.load_package(\"numpy\")` thêm các gói.", - "loadFailed": "Không tải được thời gian chạy Python.", - "showEditor": "Ghi chú nội dung", - "hideEditor": "Thay đổi kích thước bảng sổ tay", - "resizeEditor": "Thay đổi kích thước trình chỉnh sửa", - "fileNew": "Kịch bản mới", - "fileOpen": "Mở tập lệnh…", - "fileSave": "Lưu tập lệnh (Ctrl/Cmd+S)", - "fileSaveAs": "Lưu tập lệnh dưới dạng…", - "clearEditor": "Xóa trình chỉnh sửa" + "assistant": { + "setupStatus": "Trợ lý cần một nhà cung cấp AI để thực hiện việc suy nghĩ. Thêm thông tin xác thực cho bất kỳ nhà cung cấp nào bên dưới và bảng điều khiển sẽ chuyển sang chế độ trò chuyện ngay khi bạn lưu.", + "setupProviders": "Thêm thông tin xác thực cho bất kỳ nhà cung cấp nào", + "setupOpenSettings": "Mở Cài đặt → Nhà cung cấp AI", + "thinking": "Đang làm việc…", + "toolError": "thất bại", + "provider": "Nhà cung cấp LLM", + "profile": "Hồ sơ AI", + "deploymentProxy": "Proxy triển khai", + "model": "Người mẫu", + "codeApprovalTitle": "Chạy mã trợ lý?", + "codeApprovalBody": "Trợ lý muốn chạy mã {{language}} trong ứng dụng. Hãy xem lại trước - nó có thể thay đổi bản đồ và truy cập dữ liệu ứng dụng.", + "codeApprovalAlways": "Cho phép mã trợ lý cho phần còn lại của phiên này", + "codeApprovalRun": "Chạy", + "codeApprovalDecline": "Hủy bỏ", + "title": "Trợ lý muốn chạy mã __PH0__ trong ứng dụng. Hãy xem lại trước - nó có thể thay đổi bản đồ và truy cập dữ liệu ứng dụng.", + "resize": "Thay đổi kích thước bảng trợ lý", + "clear": "Xóa cuộc trò chuyện", + "close": "Đóng trợ lý", + "send": "Gửi", + "sendHint": "Gửi (Ctrl/Cmd+Enter)", + "stop": "Dừng lại", + "placeholder": "Hỏi về dữ liệu của bạn, ví dụ: \"hiển thị các quốc gia có dân số trên 50 triệu\" · Ctrl/Cmd+Enter để gửi", + "intro": "Trò chuyện với dữ liệu của bạn. Tôi có thể chạy Spatial SQL, thêm hoặc xóa các lớp, định kiểu lại chúng và di chuyển bản đồ - mọi thay đổi đều không thể hoàn tác được. Hãy thử \"tô màu các quốc gia theo dân số bằng đường dốc màu đỏ chia độ\".", + "setupTitle": "Thiết lập Trợ lý AI" }, "storymap": { "field": { @@ -3873,38 +3899,12 @@ "resetTitle": "Xóa câu chuyện và bắt đầu lại", "resetConfirm": "Xóa bản đồ câu chuyện này? Tất cả các chương và cài đặt sẽ bị xóa để bạn có thể bắt đầu chương trình của riêng mình.", "opacityLabel": "Độ mờ", - "toggleNav": "Chuyển đổi danh sách chương", - "chapterNav": "chương truyện", - "durationLabel": "Chuyển tiếp (ms)", - "dragHint": "Kéo để di chuyển (nhấp đúp để đặt lại)", - "resizeHint": "Kéo để thay đổi kích thước", - "import": "Nhập khẩu" - }, - "assistant": { - "setupStatus": "Trợ lý cần một nhà cung cấp AI để thực hiện việc suy nghĩ. Thêm thông tin xác thực cho bất kỳ nhà cung cấp nào bên dưới và bảng điều khiển sẽ chuyển sang chế độ trò chuyện ngay khi bạn lưu.", - "setupProviders": "Thêm thông tin xác thực cho bất kỳ nhà cung cấp nào", - "setupOpenSettings": "Mở Cài đặt → Nhà cung cấp AI", - "thinking": "Đang làm việc…", - "toolError": "thất bại", - "provider": "Nhà cung cấp LLM", - "profile": "Hồ sơ AI", - "deploymentProxy": "Proxy triển khai", - "model": "Người mẫu", - "codeApprovalTitle": "Chạy mã trợ lý?", - "codeApprovalBody": "Trợ lý muốn chạy mã {{language}} trong ứng dụng. Hãy xem lại trước - nó có thể thay đổi bản đồ và truy cập dữ liệu ứng dụng.", - "codeApprovalAlways": "Cho phép mã trợ lý cho phần còn lại của phiên này", - "codeApprovalRun": "Chạy", - "codeApprovalDecline": "Hủy bỏ", - "title": "Trợ lý muốn chạy mã __PH0__ trong ứng dụng. Hãy xem lại trước - nó có thể thay đổi bản đồ và truy cập dữ liệu ứng dụng.", - "resize": "Thay đổi kích thước bảng trợ lý", - "clear": "Xóa cuộc trò chuyện", - "close": "Đóng trợ lý", - "send": "Gửi", - "sendHint": "Gửi (Ctrl/Cmd+Enter)", - "stop": "Dừng lại", - "placeholder": "Hỏi về dữ liệu của bạn, ví dụ: \"hiển thị các quốc gia có dân số trên 50 triệu\" · Ctrl/Cmd+Enter để gửi", - "intro": "Trò chuyện với dữ liệu của bạn. Tôi có thể chạy Spatial SQL, thêm hoặc xóa các lớp, định kiểu lại chúng và di chuyển bản đồ - mọi thay đổi đều không thể hoàn tác được. Hãy thử \"tô màu các quốc gia theo dân số bằng đường dốc màu đỏ chia độ\".", - "setupTitle": "Thiết lập Trợ lý AI" + "toggleNav": "Chuyển đổi danh sách chương", + "chapterNav": "chương truyện", + "durationLabel": "Chuyển tiếp (ms)", + "dragHint": "Kéo để di chuyển (nhấp đúp để đặt lại)", + "resizeHint": "Kéo để thay đổi kích thước", + "import": "Nhập khẩu" }, "network": { "heading": "Mạng", @@ -3923,6 +3923,17 @@ "outputPlaceholder": "Đầu ra sẽ xuất hiện ở đây." }, "processing": { + "filePicker": { + "filePath": "Đường dẫn tệp", + "chooseFile": "Chọn tập tin", + "chooseFilePlaceholder": "Chọn một tập tin", + "chooseInputFile": "Chọn tập tin đầu vào", + "chooseOutputFile": "Chọn tập tin đầu ra" + }, + "searchTools": "Công cụ tìm kiếm", + "refreshCatalog": "Làm mới danh mục", + "increaseValue": "Tăng giá trị", + "decreaseValue": "Giảm giá trị", "distance": { "units": { "degrees": "Độ", @@ -3953,17 +3964,42 @@ "count_other": "{{count}} lần chạy được ghi lại", "toolUnavailable": "Công cụ \"{{toolId}}\" không còn khả dụng" }, - "filePicker": { - "filePath": "Đường dẫn tệp", - "chooseFile": "Chọn tập tin", - "chooseFilePlaceholder": "Chọn một tập tin", - "chooseInputFile": "Chọn tập tin đầu vào", - "chooseOutputFile": "Chọn tập tin đầu ra" + "modelBuilder": { + "moveStepUp": "Tiến bước lên", + "moveStepDown": "Di chuyển bước xuống", + "removeStep": "Xóa bước", + "title": "Tìm kiếm hệ quy chiếu tọa độ", + "description": "Chạy công cụ vectơ trên nhiều lớp hoặc xâu chuỗi các công cụ thành mô hình có thể sử dụng lại được lưu cùng với dự án của bạn.", + "tabBatch": "Lô", + "tabModels": "Người mẫu", + "outputPlaceholder": "Đầu ra sẽ xuất hiện ở đây.", + "tool": "Dụng cụ", + "sharedParameters": "Thông số được chia sẻ", + "noExtraParameters": "Công cụ này không có tham số bổ sung.", + "inputLayers": "Lớp đầu vào", + "selectAll": "Chọn tất cả", + "clearSelection": "Thông thoáng", + "noCompatibleLayers": "Không có lớp GeoJSON tương thích.", + "newModel": "Mẫu mới", + "noSavedModels": "Chọn một lớp...", + "untitledModel": "Người mẫu không có tiêu đề", + "modelName": "Tên mẫu", + "emptyPipelineHint": "Thêm một bước để bắt đầu xây dựng quy trình. Bước đầu tiên đọc lớp đầu vào; mỗi bước sau sẽ nhận được đầu ra của bước trước.", + "addStep": "Thêm bước", + "runModel": "Chạy mô hình", + "deleteModel": "Xóa bỏ", + "canvas": "Khung quy trình không gian", + "importPipeline": "Nhập quy trình", + "exportPipeline": "Xuất quy trình", + "inputPreviousStep": "Đầu vào: ← đầu ra của bước trước", + "unknownTool": "Công cụ không xác định \"{{id}}\"", + "noParameters": "Không có tham số." + }, + "parameterField": { + "selectLayer": "Sao chép liên kết có thể chia sẻ để mở công cụ này với cài đặt hiện tại", + "selectField": "Chọn một trường...", + "selectLayerFirst": "Chọn một lớp đầu tiên" }, - "searchTools": "Công cụ tìm kiếm", - "refreshCatalog": "Làm mới danh mục", - "increaseValue": "Tăng giá trị", - "decreaseValue": "Giảm giá trị", "whitebox": { "drawBbox": "Vẽ trên bản đồ", "drawingBbox": "Vẽ…", @@ -4040,39 +4076,6 @@ "copyLinkCopied": "Đã sao chép!", "vectorUnitsNote": "Các lớp vectơ được đọc dưới dạng WGS84, do đó, các giá trị khoảng cách, khoảng cách và dung sai được tính bằng độ chứ không phải mét. 1° là khoảng 111 km và 0,001° là khoảng 111 m." }, - "modelBuilder": { - "noSavedModels": "Chọn một lớp...", - "untitledModel": "Người mẫu không có tiêu đề", - "modelName": "Tên mẫu", - "emptyPipelineHint": "Thêm một bước để bắt đầu xây dựng quy trình. Bước đầu tiên đọc lớp đầu vào; mỗi bước sau sẽ nhận được đầu ra của bước trước.", - "addStep": "Thêm bước", - "runModel": "Chạy mô hình", - "deleteModel": "Xóa bỏ", - "inputPreviousStep": "Đầu vào: ← đầu ra của bước trước", - "unknownTool": "Công cụ không xác định \"{{id}}\"", - "noParameters": "Không có tham số.", - "moveStepUp": "Tiến bước lên", - "moveStepDown": "Di chuyển bước xuống", - "removeStep": "Xóa bước", - "title": "Tìm kiếm hệ quy chiếu tọa độ", - "description": "Chạy công cụ vectơ trên nhiều lớp hoặc xâu chuỗi các công cụ thành mô hình có thể sử dụng lại được lưu cùng với dự án của bạn.", - "tabBatch": "Lô", - "tabModels": "Người mẫu", - "outputPlaceholder": "Đầu ra sẽ xuất hiện ở đây.", - "tool": "Dụng cụ", - "sharedParameters": "Thông số được chia sẻ", - "noExtraParameters": "Công cụ này không có tham số bổ sung.", - "inputLayers": "Lớp đầu vào", - "selectAll": "Chọn tất cả", - "clearSelection": "Thông thoáng", - "noCompatibleLayers": "Không có lớp GeoJSON tương thích.", - "newModel": "Mẫu mới" - }, - "parameterField": { - "selectLayer": "Sao chép liên kết có thể chia sẻ để mở công cụ này với cài đặt hiện tại", - "selectField": "Chọn một trường...", - "selectLayerFirst": "Chọn một lớp đầu tiên" - }, "sidecar": { "runLocallyButton": "Chạy cục bộ (WASM)", "troubleshootingTitle": "Bạn vẫn muốn sử dụng máy chủ?", @@ -4141,29 +4144,6 @@ "failed": "Phân đoạn không thành công." } }, - "segmentEverything": { - "imageLabel": "Hình ảnh (GeoTIFF)", - "imagePlaceholder": "Chọn GeoTIFF…", - "chooseImage": "Chọn hình ảnh", - "gridLabel": "Lưới", - "qualityLabel": "Chất lượng", - "minSizeLabel": "Kích thước tối thiểu %", - "run": "Phân đoạn", - "downloadingModel": "Đang tải xuống mô hình…", - "progress": "Đang phân đoạn… {{done}}/{{total}}", - "noObjects": "Không có đối tượng nào được phân đoạn. Hãy thử lưới dày đặc hơn hoặc ngưỡng chất lượng thấp hơn.", - "title": "Phân đoạn mọi thứ", - "description": "Chạy SlimSAM tự động trên GeoTIFF, hoàn toàn trong trình duyệt. Mọi đối tượng được phát hiện sẽ trở thành một đa giác tham chiếu địa lý được thêm vào dưới dạng một lớp.", - "hint": "Một lưới các điểm được lấy mẫu trên hình ảnh và được phân đoạn bằng SlimSAM (được tải xuống và lưu vào bộ nhớ đệm một lần). Không có nhấp chuột hoặc nhãn — lưới dày đặc hơn sẽ tìm thấy nhiều đối tượng hơn nhưng mất nhiều thời gian hơn.", - "unavailableNoExternalCdn": "“Phân đoạn mọi thứ” không khả dụng trong bản dựng này. Tính năng này cần ONNX Runtime, vốn được tải từ một CDN bên ngoài mà bản triển khai này đã tắt.", - "added": "Đã thêm {{count}} phân đoạn.", - "layerName": "Hiển thị tất cả tính năng", - "error": { - "chooseImage": "Chọn một hình ảnh (GeoTIFF) để phân đoạn.", - "downloadModel": "Không thể tải xuống mô hình. Hãy kiểm tra kết nối của bạn và thử lại.", - "failed": "Phân đoạn không thành công." - } - }, "objectDetection": { "description": "Chạy mô hình YOLO được xuất sang ONNX qua GeoTIFF hoặc ảnh được gắn thẻ địa lý, hoàn toàn trong trình duyệt. Mỗi lớp được phát hiện sẽ được thêm vào dưới dạng lớp riêng của nó.", "hint": "Sử dụng mô hình COCO tích hợp sẵn (được tải xuống và lưu vào bộ nhớ đệm một lần) hoặc mang theo ONNX YOLOv5/v8/v11 của riêng bạn. Phát hiện GeoTIFF trở thành hộp tham chiếu địa lý. Việc phát hiện ảnh trở thành các điểm tại vị trí GPS của máy ảnh vì ảnh không xác định dấu chân trên mặt đất.", @@ -4197,6 +4177,48 @@ "failed": "Phát hiện không thành công." } }, + "segmentEverything": { + "imageLabel": "Hình ảnh (GeoTIFF)", + "imagePlaceholder": "Chọn GeoTIFF…", + "chooseImage": "Chọn hình ảnh", + "gridLabel": "Lưới", + "qualityLabel": "Chất lượng", + "minSizeLabel": "Kích thước tối thiểu %", + "run": "Phân đoạn", + "downloadingModel": "Đang tải xuống mô hình…", + "progress": "Đang phân đoạn… {{done}}/{{total}}", + "noObjects": "Không có đối tượng nào được phân đoạn. Hãy thử lưới dày đặc hơn hoặc ngưỡng chất lượng thấp hơn.", + "title": "Phân đoạn mọi thứ", + "description": "Chạy SlimSAM tự động trên GeoTIFF, hoàn toàn trong trình duyệt. Mọi đối tượng được phát hiện sẽ trở thành một đa giác tham chiếu địa lý được thêm vào dưới dạng một lớp.", + "hint": "Một lưới các điểm được lấy mẫu trên hình ảnh và được phân đoạn bằng SlimSAM (được tải xuống và lưu vào bộ nhớ đệm một lần). Không có nhấp chuột hoặc nhãn — lưới dày đặc hơn sẽ tìm thấy nhiều đối tượng hơn nhưng mất nhiều thời gian hơn.", + "unavailableNoExternalCdn": "“Phân đoạn mọi thứ” không khả dụng trong bản dựng này. Tính năng này cần ONNX Runtime, vốn được tải từ một CDN bên ngoài mà bản triển khai này đã tắt.", + "added": "Đã thêm {{count}} phân đoạn.", + "layerName": "Hiển thị tất cả tính năng", + "error": { + "chooseImage": "Chọn một hình ảnh (GeoTIFF) để phân đoạn.", + "downloadModel": "Không thể tải xuống mô hình. Hãy kiểm tra kết nối của bạn và thử lại.", + "failed": "Phân đoạn không thành công." + } + }, + "pluginPanel": { + "expand": "Bảng mở rộng", + "collapse": "Thu gọn bảng điều khiển", + "close": "Đóng bảng điều khiển", + "resize": "Thay đổi kích thước bảng điều khiển", + "moveLeft": "Di chuyển bảng sang trái", + "moveRight": "Di chuyển bảng sang phải", + "mergeIntoStyleRail": "Hợp nhất vào đường ray Style", + "mergeIntoLayersRail": "Hợp nhất vào đường ray Lớp", + "detach": "Tách ra như một bảng điều khiển di động", + "collapsedLabel": "{{title}} (đã thu gọn)" + }, + "sharedRail": { + "label": "Thanh bên", + "layers": "Lớp", + "style": "Phong cách", + "expand": "Mở rộng {{title}}", + "collapse": "Thu gọn {{title}}" + }, "attributeTable": { "chart": { "yAxis": "Trục Y", @@ -4342,25 +4364,6 @@ "moveRight": "Di chuyển sang phải", "deleteField": "Xóa trường" }, - "pluginPanel": { - "expand": "Bảng mở rộng", - "collapse": "Thu gọn bảng điều khiển", - "close": "Đóng bảng điều khiển", - "resize": "Thay đổi kích thước bảng điều khiển", - "moveLeft": "Di chuyển bảng sang trái", - "moveRight": "Di chuyển bảng sang phải", - "mergeIntoStyleRail": "Hợp nhất vào đường ray Style", - "mergeIntoLayersRail": "Hợp nhất vào đường ray Lớp", - "detach": "Tách ra như một bảng điều khiển di động", - "collapsedLabel": "{{title}} (đã thu gọn)" - }, - "sharedRail": { - "label": "Thanh bên", - "layers": "Lớp", - "style": "Phong cách", - "expand": "Mở rộng {{title}}", - "collapse": "Thu gọn {{title}}" - }, "styleManager": { "title": "Trình quản lý phong cách", "saveButton": "Lưu kiểu", @@ -4906,6 +4909,10 @@ "columnTaken": "“{{name}}” đã là một trường thuộc tính. Việc theo dõi sẽ ghi đè các giá trị của nó." } }, + "vectorExport": { + "invalidKmlCoordinatesByPosition": "Xuất KML không thể ghi tính năng {{position}} vì nó có tọa độ không hợp lệ.", + "invalidKmlCoordinatesById": "Xuất KML không thể ghi ID tính năng {{id}} vì nó có tọa độ không hợp lệ." + }, "layers": { "exportStyleNeedsFeatures": "Dán kiểu từ \"__PH0__\"", "exportStyleError": "Không thể xuất kiểu của lớp này.", @@ -5102,10 +5109,6 @@ "exportNeedsFeatures": "Xuất yêu cầu một lớp vectơ có các tính năng.", "exportedWithWarnings": "Đã xuất lớp. {{warnings}}" }, - "vectorExport": { - "invalidKmlCoordinatesByPosition": "Xuất KML không thể ghi tính năng {{position}} vì nó có tọa độ không hợp lệ.", - "invalidKmlCoordinatesById": "Xuất KML không thể ghi ID tính năng {{id}} vì nó có tọa độ không hợp lệ." - }, "timeSliderSymbology": { "bandsHint": "Số băng tần dựa trên 1, được phân tách bằng dấu phẩy (ví dụ: 4,3,2). Để trống cho mặc định của trình kết xuất.", "bandsInvalid": "Nhập toàn bộ số nhóm từ 1 trở lên, phân tách bằng dấu phẩy.", @@ -5269,6 +5272,16 @@ "legendTitle": "Điền vào chú giải trên bản đồ các lớp của bảng", "csvTitle": "Xuất bảng sang CSV" }, + "raster": { + "filePickerLabel": "Giá trị", + "cogConvertConfirm": "Chuyển đổi {{name}}?", + "cogConvertRemoteConfirm": "Chuyển đổi {{name}}?", + "cogConvertLargeConfirm": "\"{{name}}\" là {{width}}×{{height}} GeoTIFF lớn không phải là GeoTIFF được tối ưu hóa cho đám mây. Việc chuyển đổi nó trong trình duyệt có thể chậm và tốn nhiều bộ nhớ. Vẫn chuyển đổi và tải nó?", + "cogConvertTooLarge": "\"{{name}}\" quá lớn để chuyển đổi an toàn trong trình duyệt. Hãy chuyển đổi tệp thành Cloud-Optimized GeoTIFF bằng gdal_translate hoặc rio cogeo, rồi tải kết quả.", + "cogConvertFailed": "Không thể chuyển đổi \"{{name}}\" thành GeoTIFF được tối ưu hóa cho đám mây. Hãy thử chuyển đổi nó bằng gdal_translate hoặc rio cogeo, sau đó tải kết quả.", + "rasterDownloadFailed": "Không thể tải xuống {{name}}.", + "rasterNotGeotiff": "\"{{name}}\" không trả về GeoTIFF. Thay vào đó, máy chủ có thể đã gửi lỗi hoặc trang đăng nhập. Hãy kiểm tra URL và thử lại." + }, "attributeForm": { "error": { "range": "Giá trị phải nằm trong khoảng {{min}} đến {{max}}.", @@ -5323,16 +5336,6 @@ "itemColor": "Màu sản phẩm", "position": "Chức vụ" }, - "raster": { - "filePickerLabel": "Giá trị", - "cogConvertConfirm": "Chuyển đổi {{name}}?", - "cogConvertRemoteConfirm": "Chuyển đổi {{name}}?", - "cogConvertLargeConfirm": "\"{{name}}\" là {{width}}×{{height}} GeoTIFF lớn không phải là GeoTIFF được tối ưu hóa cho đám mây. Việc chuyển đổi nó trong trình duyệt có thể chậm và tốn nhiều bộ nhớ. Vẫn chuyển đổi và tải nó?", - "cogConvertTooLarge": "\"{{name}}\" quá lớn để chuyển đổi an toàn trong trình duyệt. Hãy chuyển đổi tệp thành Cloud-Optimized GeoTIFF bằng gdal_translate hoặc rio cogeo, rồi tải kết quả.", - "cogConvertFailed": "Không thể chuyển đổi \"{{name}}\" thành GeoTIFF được tối ưu hóa cho đám mây. Hãy thử chuyển đổi nó bằng gdal_translate hoặc rio cogeo, sau đó tải kết quả.", - "rasterDownloadFailed": "Không thể tải xuống {{name}}.", - "rasterNotGeotiff": "\"{{name}}\" không trả về GeoTIFF. Thay vào đó, máy chủ có thể đã gửi lỗi hoặc trang đăng nhập. Hãy kiểm tra URL và thử lại." - }, "errorBoundary": { "title": "Đã xảy ra lỗi", "description": "GeoLibre gặp phải lỗi không mong muốn và không thể tiếp tục. Tác phẩm của bạn có thể chưa được lưu — hãy thử khôi phục trước khi tải lại.", @@ -5347,34 +5350,6 @@ "listAria": "Lệnh", "noMatches": "Không có lệnh phù hợp" }, - "shell": { - "section": { - "sharedRightSidebar": "Thanh bên phải được chia sẻ", - "pluginPanelLeftOfStyle": "Bảng plugin (bên trái Style)", - "stylePanel": "Bảng kiểu", - "pluginPanelRightOfStyle": "Bảng plugin (bên phải Style)", - "notebook": "Sổ tay", - "attributeTable": "Bảng thuộc tính", - "rasterAttributeTable": "Bảng thuộc tính raster", - "dashboard": "Trang tổng quan", - "pythonConsole": "Bảng điều khiển Python", - "sqlWorkspace": "Không gian làm việc SQL", - "assistant": "Trợ lý", - "statusBar": "Thanh trạng thái", - "toolbar": "Thanh công cụ", - "sharedLeftSidebar": "Thanh bên trái được chia sẻ", - "pluginPanelLeftOfLayers": "Bảng plugin (bên trái Lớp)", - "layerPanel": "Bảng điều khiển lớp", - "pluginPanelRightOfLayers": "Hủy bỏ", - "map": "Bản đồ", - "pluginFloatingPanels": "Bảng nổi plugin", - "selectionPanels": "Bảng lựa chọn", - "sunSimulationPanel": "Bảng mô phỏng mặt trời", - "routeAnimationPanel": "Bảng điều khiển hoạt ảnh tuyến đường", - "flightSimulatorPanel": "Bảng mô phỏng chuyến bay" - }, - "workspaceTitle": "Không gian làm việc của bản đồ GeoLibre" - }, "shortcuts": { "title": "Phím tắt", "description": "Nhấn {{shortcut}} để tìm kiếm mọi hành động trong bảng lệnh.", @@ -5410,6 +5385,34 @@ "logAllRequestsHint": "Ghi lại các yêu cầu mạng thành công và bị hủy bỏ kể từ bây giờ; các yêu cầu được thực hiện khi quá trình đăng nhập bị tắt sẽ không được lấp đầy. Tắt theo mặc định vì việc ghi nhật ký mọi yêu cầu sẽ làm chậm ứng dụng.", "copyJson": "Sao chép JSON" }, + "shell": { + "section": { + "sharedRightSidebar": "Thanh bên phải được chia sẻ", + "pluginPanelLeftOfStyle": "Bảng plugin (bên trái Style)", + "stylePanel": "Bảng kiểu", + "pluginPanelRightOfStyle": "Bảng plugin (bên phải Style)", + "notebook": "Sổ tay", + "attributeTable": "Bảng thuộc tính", + "rasterAttributeTable": "Bảng thuộc tính raster", + "dashboard": "Trang tổng quan", + "pythonConsole": "Bảng điều khiển Python", + "sqlWorkspace": "Không gian làm việc SQL", + "assistant": "Trợ lý", + "statusBar": "Thanh trạng thái", + "toolbar": "Thanh công cụ", + "sharedLeftSidebar": "Thanh bên trái được chia sẻ", + "pluginPanelLeftOfLayers": "Bảng plugin (bên trái Lớp)", + "layerPanel": "Bảng điều khiển lớp", + "pluginPanelRightOfLayers": "Hủy bỏ", + "map": "Bản đồ", + "pluginFloatingPanels": "Bảng nổi plugin", + "selectionPanels": "Bảng lựa chọn", + "sunSimulationPanel": "Bảng mô phỏng mặt trời", + "routeAnimationPanel": "Bảng điều khiển hoạt ảnh tuyến đường", + "flightSimulatorPanel": "Bảng mô phỏng chuyến bay" + }, + "workspaceTitle": "Không gian làm việc của bản đồ GeoLibre" + }, "attributeStats": { "title": "Thống kê hiện trường", "description": "Thống kê tóm tắt cho một trường trong \"{{layer}}\".", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 4ad1a575f1..3d4c5d9d7a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -1581,6 +1581,16 @@ "exportCsv": "导出 CSV", "exportGeoParquet": "导出 GeoParquet" }, + "auth": { + "unavailableTitle": "登录不可用", + "unavailableDescription": "GeoLibre 无法访问登录服务,因此无法判断您是否已登录。这通常是暂时的;如果持续出现,可能需要检查此部署的身份验证设置。", + "retry": "重试", + "signInTitle": "登录 GeoLibre", + "signInDescription": "此部署需要账户。系统会将您带到安全的登录页面,然后返回此处。", + "signIn": "登录", + "signOut": "退出登录", + "account": "账户" + }, "basemapExtract": { "title": "提取离线底图", "url": "底图 URL", @@ -3978,6 +3988,9 @@ "addStep": "添加步骤", "runModel": "运行模型", "deleteModel": "删除", + "canvas": "空间工作流画布", + "importPipeline": "导入管道", + "exportPipeline": "导出管道", "inputPreviousStep": "输入:← 上一步的输出", "unknownTool": "未知工具“{{id}}”", "noParameters": "没有参数。" @@ -5505,15 +5518,5 @@ "mapPoint": "地图点", "reply": "回复", "replyPlaceholder": "写下回复..." - }, - "auth": { - "unavailableTitle": "登录不可用", - "unavailableDescription": "GeoLibre 无法访问登录服务,因此无法判断您是否已登录。这通常是暂时的;如果持续出现,可能需要检查此部署的身份验证设置。", - "retry": "重试", - "signInTitle": "登录 GeoLibre", - "signInDescription": "此部署需要账户。系统会将您带到安全的登录页面,然后返回此处。", - "signIn": "登录", - "signOut": "退出登录", - "account": "账户" } } diff --git a/apps/geolibre-desktop/src/lib/processing-pipeline.ts b/apps/geolibre-desktop/src/lib/processing-pipeline.ts new file mode 100644 index 0000000000..0effbfe137 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/processing-pipeline.ts @@ -0,0 +1,106 @@ +import type { ProcessingModel, ProcessingModelStep } from "@geolibre/core"; + +export const PIPELINE_SCHEMA = "https://geolibre.app/schemas/pipeline-v1.json"; + +export interface ProcessingPipelineNode { + id: string; + type: string; + name: string; + params: Record; + inputParam?: string; +} + +export interface ProcessingPipelineEdge { + from: string; + to: string; +} + +export interface ProcessingPipeline { + $schema: typeof PIPELINE_SCHEMA; + name: string; + version: "1.0.0"; + nodes: ProcessingPipelineNode[]; + edges: ProcessingPipelineEdge[]; +} + +/** Convert the app's sequential model into the portable DAG interchange format. */ +export function modelToPipeline(model: ProcessingModel): ProcessingPipeline { + return { + $schema: PIPELINE_SCHEMA, + name: model.name, + version: "1.0.0", + nodes: model.steps.map((step) => ({ + id: step.id, + type: `transform.vector.${step.toolId}`, + name: step.toolId, + params: { ...step.parameters }, + ...(step.inputParam ? { inputParam: step.inputParam } : {}), + })), + edges: model.steps.slice(1).map((step, index) => ({ + from: model.steps[index].id, + to: step.id, + })), + }; +} + +/** Parse a pipeline and require the single, ordered chain supported by the current runner. */ +export function pipelineToModel(value: unknown, createId: () => string): ProcessingModel { + if (!value || typeof value !== "object") throw new Error("Pipeline must be a JSON object"); + const pipeline = value as Partial; + if (pipeline.$schema !== PIPELINE_SCHEMA || pipeline.version !== "1.0.0") { + throw new Error("Unsupported pipeline schema or version"); + } + if (!Array.isArray(pipeline.nodes) || !Array.isArray(pipeline.edges)) { + throw new Error("Pipeline nodes and edges must be arrays"); + } + const nodes = pipeline.nodes; + const nodeById = new Map(); + for (const node of nodes) { + if (!node || typeof node.id !== "string" || nodeById.has(node.id)) { + throw new Error("Every pipeline node must have a unique id"); + } + if ( + !node.type?.startsWith("transform.vector.") || + !node.params || + typeof node.params !== "object" + ) { + throw new Error(`Unsupported pipeline node "${node.id}"`); + } + nodeById.set(node.id, node); + } + + const next = new Map(); + const incoming = new Map(); + for (const edge of pipeline.edges) { + if (!nodeById.has(edge?.from) || !nodeById.has(edge?.to) || edge.from === edge.to) { + throw new Error("Pipeline contains an invalid edge"); + } + if (next.has(edge.from) || (incoming.get(edge.to) ?? 0) > 0) { + throw new Error("Branching pipelines are not supported yet"); + } + next.set(edge.from, edge.to); + incoming.set(edge.to, 1); + } + if (nodes.length > 0 && pipeline.edges.length !== nodes.length - 1) { + throw new Error("Pipeline must contain one connected chain"); + } + const starts = nodes.filter((node) => !incoming.has(node.id)); + if (nodes.length > 0 && starts.length !== 1) throw new Error("Pipeline contains a cycle"); + + const ordered: ProcessingPipelineNode[] = []; + let current: ProcessingPipelineNode | undefined = starts[0]; + while (current) { + ordered.push(current); + const nextId = next.get(current.id); + current = nextId ? nodeById.get(nextId) : undefined; + } + if (ordered.length !== nodes.length) throw new Error("Pipeline contains a cycle"); + + const steps: ProcessingModelStep[] = ordered.map((node) => ({ + id: node.id || createId(), + toolId: node.type.slice("transform.vector.".length), + parameters: { ...node.params }, + ...(node.inputParam ? { inputParam: node.inputParam } : {}), + })); + return { id: createId(), name: String(pipeline.name || "Imported model"), steps }; +} diff --git a/tests/processing-pipeline.test.ts b/tests/processing-pipeline.test.ts new file mode 100644 index 0000000000..a0e0534782 --- /dev/null +++ b/tests/processing-pipeline.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + modelToPipeline, + pipelineToModel, +} from "../apps/geolibre-desktop/src/lib/processing-pipeline"; + +describe("processing pipeline JSON", () => { + it("round trips an ordered model through explicit nodes and edges", () => { + const pipeline = modelToPipeline({ + id: "model-1", + name: "Buffer and dissolve", + steps: [ + { id: "buffer", toolId: "buffer", parameters: { layer: "cities", distance: 5 } }, + { id: "dissolve", toolId: "dissolve", parameters: { field: "state" } }, + ], + }); + assert.deepEqual(pipeline.edges, [{ from: "buffer", to: "dissolve" }]); + const model = pipelineToModel(pipeline, () => "imported-id"); + assert.equal(model.name, "Buffer and dissolve"); + assert.deepEqual( + model.steps.map((step) => step.toolId), + ["buffer", "dissolve"], + ); + }); + + it("rejects branching graphs until the runner supports multiple inputs", () => { + assert.throws( + () => + pipelineToModel( + { + $schema: "https://geolibre.app/schemas/pipeline-v1.json", + version: "1.0.0", + name: "Branch", + nodes: ["a", "b", "c"].map((id) => ({ + id, + type: "transform.vector.buffer", + name: id, + params: {}, + })), + edges: [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + ], + }, + () => "id", + ), + /Branching pipelines/, + ); + }); +}); From 180ecb518833533a1f9a705735ecf2b1154eac1f Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 17 Aug 2026 19:05:32 -0400 Subject: [PATCH 02/22] Address Claude review feedback - stop card action clicks from corrupting node selection - validate pipeline node types and parameter objects - cover cyclic and disconnected pipeline imports --- .../processing/ModelBuilderDialog.tsx | 15 ++++- .../src/lib/processing-pipeline.ts | 6 +- tests/processing-pipeline.test.ts | 59 +++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx b/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx index 18b4ea2acf..7e68589cc8 100644 --- a/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx @@ -916,7 +916,10 @@ function StepCard({
+ +
+ {/* Shared parameters */} +
+ + {sharedParams.filter((p) => isParamVisible(p, params)).length === 0 ? ( +

+ {t("processing.batchTools.noExtraParameters")} +

+ ) : ( + sharedParams + .filter((p) => isParamVisible(p, params)) + .map((param) => ( + handleParamChange(param.id, value)} + /> + )) + )} +
+ + {/* Input layers to iterate over */} +
+
+ + {inputLayers.length > 0 ? ( + + ) : null} +
+ + {inputLayers.length === 0 ? ( +

+ {t("processing.batchTools.noCompatibleLayers")} +

+ ) : ( + inputLayers.map((layer) => ( + + )) + )} +
+
+
+ +
+ +
+ + +
+ ); +} diff --git a/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx b/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx deleted file mode 100644 index 684c8ed87e..0000000000 --- a/apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx +++ /dev/null @@ -1,999 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - useAppStore, - type GeoLibreLayer, - type ProcessingModel, - type ProcessingModelStep, -} from "@geolibre/core"; -import { detectGeometryProfile, type MapController } from "@geolibre/map"; -import { - VECTOR_TOOLS, - getVectorTool, - runAlgorithmCapture, - runModel, - type AlgorithmParameter, - type GeometryFamily, - type ProcessingAlgorithm, - type RunnerHost, -} from "@geolibre/processing"; -import { createDuckDbCapability } from "../../lib/duckdb-processing"; -import { modelToPipeline, pipelineToModel } from "../../lib/processing-pipeline"; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - Input, - Label, - ScrollArea, - Select, - Separator, - cn, -} from "@geolibre/ui"; -import { ParameterField } from "./ParameterField"; -import { - ArrowDown, - ArrowUp, - Download, - Layers, - Loader2, - Play, - Plus, - Save, - Trash2, - Upload, - Workflow, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; - -interface ModelBuilderDialogProps { - mapControllerRef: React.RefObject; -} - -/** The conventional id of a tool's primary input layer parameter. */ -const PRIMARY_INPUT_PARAM = "layer"; -/** Sample size when scanning a layer's attribute field names. */ -const FIELD_SCAN_SAMPLE = 1000; - -/** A best-effort unique id (webview always has crypto.randomUUID). */ -function createId(): string { - return typeof crypto !== "undefined" && crypto.randomUUID - ? crypto.randomUUID() - : `id-${Math.floor(performance.now())}-${VECTOR_TOOLS.length}`; -} - -/** Vector tools grouped by their `group` label, preserving registry order. */ -function groupedTools(): { group: string; tools: ProcessingAlgorithm[] }[] { - const groups: { group: string; tools: ProcessingAlgorithm[] }[] = []; - for (const tool of VECTOR_TOOLS) { - const label = tool.group ?? "Tools"; - let entry = groups.find((g) => g.group === label); - if (!entry) { - entry = { group: label, tools: [] }; - groups.push(entry); - } - entry.tools.push(tool); - } - return groups; -} - -/** Render a ` setToolId(e.target.value)}> - - -

{tool.description}

-
- -
- {/* Shared parameters */} -
- - {sharedParams.filter((p) => isParamVisible(p, params)).length === 0 ? ( -

- {t("processing.modelBuilder.noExtraParameters")} -

- ) : ( - sharedParams - .filter((p) => isParamVisible(p, params)) - .map((param) => ( - handleParamChange(param.id, value)} - /> - )) - )} -
- - {/* Input layers to iterate over */} -
-
- - {inputLayers.length > 0 ? ( - - ) : null} -
- - {inputLayers.length === 0 ? ( -

- {t("processing.modelBuilder.noCompatibleLayers")} -

- ) : ( - inputLayers.map((layer) => ( - - )) - )} -
-
-
- -
- -
- - -
- ); -} - -/** Models mode: chain tools into a saved, re-runnable pipeline. */ -function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement { - const { t } = useTranslation(); - const layers = useAppStore((s) => s.layers); - const addGeoJsonLayer = useAppStore((s) => s.addGeoJsonLayer); - const models = useAppStore((s) => s.models); - const saveModel = useAppStore((s) => s.saveModel); - const deleteModel = useAppStore((s) => s.deleteModel); - const duckdb = useMemo(() => createDuckDbCapability(), []); - - const [draft, setDraft] = useState(() => ({ - id: createId(), - name: "Untitled model", - steps: [], - })); - const [addToolId, setAddToolId] = useState(VECTOR_TOOLS[0].id); - const [log, setLog] = useState([]); - const [running, setRunning] = useState(false); - const [selectedStepId, setSelectedStepId] = useState(null); - const importRef = useRef(null); - - const appendLog = useCallback((message: string) => setLog((prev) => [...prev, message]), []); - const fieldsByLayer = useFieldsByLayer(layers, true); - const isSaved = models.some((m) => m.id === draft.id); - - const newDraft = useCallback(() => { - setDraft({ id: createId(), name: "Untitled model", steps: [] }); - setSelectedStepId(null); - setLog([]); - }, []); - - const loadModel = useCallback((model: ProcessingModel) => { - // Deep clone so editing the draft never mutates the stored model. - setDraft({ - id: model.id, - name: model.name, - steps: model.steps.map((s) => ({ ...s, parameters: { ...s.parameters } })), - }); - setSelectedStepId(model.steps[0]?.id ?? null); - setLog([]); - }, []); - - const addStep = useCallback(() => { - const tool = getVectorTool(addToolId); - if (!tool) return; - const id = createId(); - setDraft((prev) => ({ - ...prev, - steps: [...prev.steps, { id, toolId: tool.id, parameters: defaultParams(tool) }], - })); - setSelectedStepId(id); - }, [addToolId]); - - const removeStep = useCallback((stepId: string) => { - setDraft((prev) => ({ - ...prev, - steps: prev.steps.filter((s) => s.id !== stepId), - })); - setSelectedStepId((current) => (current === stepId ? null : current)); - }, []); - - const handleExport = useCallback(() => { - const json = JSON.stringify(modelToPipeline(draft), null, 2); - const url = URL.createObjectURL(new Blob([json], { type: "application/json" })); - const anchor = document.createElement("a"); - // Keep Unicode letters/digits: an ASCII-only slug empties out for a model - // named in any of the non-Latin scripts this app ships locales for, so the - // export silently falls back to the generic name. - const slug = draft.name - .trim() - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, "-") - .replace(/^-|-$/g, ""); - anchor.href = url; - anchor.download = `${slug || "pipeline"}.pipeline.json`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - // Defer revoke so the browser can fetch the blob first (Firefox races and - // silently drops the download if the URL is revoked synchronously). - setTimeout(() => URL.revokeObjectURL(url), 0); - appendLog(`Exported ${anchor.download}`); - }, [draft, appendLog]); - - const handleImport = useCallback( - async (file: File) => { - try { - const model = pipelineToModel(JSON.parse(await file.text()), createId); - for (const step of model.steps) { - if (!getVectorTool(step.toolId)) throw new Error(`Unknown vector tool "${step.toolId}"`); - } - setDraft(model); - setSelectedStepId(model.steps[0]?.id ?? null); - setLog([`Imported ${file.name}`]); - } catch (error) { - appendLog(`Error: ${(error as Error).message}`); - } - }, - [appendLog], - ); - - const moveStep = useCallback((stepId: string, dir: -1 | 1) => { - setDraft((prev) => { - const index = prev.steps.findIndex((s) => s.id === stepId); - const target = index + dir; - if (index < 0 || target < 0 || target >= prev.steps.length) return prev; - const steps = [...prev.steps]; - [steps[index], steps[target]] = [steps[target], steps[index]]; - return { ...prev, steps }; - }); - }, []); - - const updateStepParam = useCallback((stepId: string, paramId: string, value: unknown) => { - setDraft((prev) => ({ - ...prev, - steps: prev.steps.map((step) => { - if (step.id !== stepId) return step; - const tool = getVectorTool(step.toolId); - const parameters = { ...step.parameters, [paramId]: value }; - // Clear a field parameter when its source layer changes. - if (tool) { - for (const param of tool.parameters) { - if (param.type === "field" && (param.fieldSource ?? PRIMARY_INPUT_PARAM) === paramId) { - parameters[param.id] = undefined; - } - } - } - return { ...step, parameters }; - }), - })); - }, []); - - const handleSave = useCallback(() => { - const name = draft.name.trim(); - if (!name) { - appendLog("Error: give the model a name before saving"); - return; - } - if (draft.steps.length === 0) { - appendLog("Error: add at least one step before saving"); - return; - } - saveModel({ ...draft, name }); - appendLog(`Saved model "${name}"`); - }, [draft, saveModel, appendLog]); - - const handleDelete = useCallback(() => { - deleteModel(draft.id); - appendLog(`Deleted model "${draft.name}"`); - newDraft(); - }, [deleteModel, draft.id, draft.name, appendLog, newDraft]); - - const handleRun = useCallback(async () => { - setLog([]); - if (draft.steps.length === 0) { - appendLog("Error: the model has no steps"); - return; - } - const firstStep = draft.steps[0]; - const inputParam = firstStep.inputParam ?? PRIMARY_INPUT_PARAM; - const inputId = firstStep.parameters[inputParam]; - if (!inputId || !layers.some((l) => l.id === inputId)) { - appendLog("Error: pick an input layer for the first step"); - return; - } - - setRunning(true); - const host: RunnerHost = { - layers, - log: appendLog, - duckdb, - viewportBounds: viewportBoundsReader(mapControllerRef), - }; - try { - const results = await runModel(draft, host); - const final = results[results.length - 1]; - if (results.every((r) => !r.error) && final?.output?.features.length) { - addGeoJsonLayer(draft.name.trim() || final.toolName, final.output); - appendLog(`Model complete: added "${draft.name.trim()}"`); - } else if (results.some((r) => r.error)) { - appendLog("Model stopped before completing (see errors above)"); - } else { - appendLog("Model produced no features"); - } - } catch (error) { - appendLog(`Error: ${(error as Error).message}`); - } finally { - setRunning(false); - } - }, [draft, layers, appendLog, duckdb, mapControllerRef, addGeoJsonLayer]); - - return ( -
- {/* Saved models */} -
- - - {models.length === 0 ? ( -

- {t("processing.modelBuilder.noSavedModels")} -

- ) : ( - models.map((model) => ( - - )) - )} -
-
- - {/* Editor */} -
-
- - setDraft((prev) => ({ ...prev, name: e.target.value }))} - /> -
- - - - - {draft.steps.length === 0 ? ( -

- {t("processing.modelBuilder.emptyPipelineHint")} -

- ) : ( -
- {draft.steps.map((step, index) => ( - updateStepParam(step.id, paramId, value)} - onRemove={() => removeStep(step.id)} - onMove={(dir) => moveStep(step.id, dir)} - selected={step.id === selectedStepId} - onSelect={() => setSelectedStepId(step.id)} - /> - ))} -
- )} -
- -
-
- - -
- -
- - - -
- - - - - { - const file = event.target.files?.[0]; - if (file) void handleImport(file); - event.target.value = ""; - }} - /> - -
- - -
-
- ); -} - -/** Compact node-and-edge canvas for the ordered graph executed by the model runner. */ -function WorkflowCanvas({ - steps, - selectedStepId, - onSelect, -}: { - steps: ProcessingModelStep[]; - selectedStepId: string | null; - onSelect: (id: string) => void; -}): ReactElement { - const { t } = useTranslation(); - return ( -
- {steps.length === 0 ? ( -

- {t("processing.modelBuilder.canvasEmpty")} -

- ) : ( -
- {steps.map((step, index) => { - const tool = getVectorTool(step.toolId); - return ( -
- {index > 0 ? ( - - )} -
- ); -} - -interface StepCardProps { - step: ProcessingModelStep; - index: number; - total: number; - layers: GeoLibreLayer[]; - fieldsByLayer: Map; - onParamChange: (paramId: string, value: unknown) => void; - onRemove: () => void; - onMove: (dir: -1 | 1) => void; - selected: boolean; - onSelect: () => void; -} - -/** One step in the model editor: its tool, parameters, and reorder controls. */ -function StepCard({ - step, - index, - total, - layers, - fieldsByLayer, - onParamChange, - onRemove, - onMove, - selected, - onSelect, -}: StepCardProps): ReactElement { - const { t } = useTranslation(); - const tool = getVectorTool(step.toolId); - const inputParam = step.inputParam ?? PRIMARY_INPUT_PARAM; - const isFirst = index === 0; - - const layerOptions = useCallback( - (filter?: GeometryFamily[]) => geojsonLayers(layers, filter), - [layers], - ); - - // The chained input parameter is hidden on every step after the first (the - // runner supplies it from the previous step's output). Hidden `visibleWhen` - // parameters are skipped too. - const visibleParams = (tool?.parameters ?? []).filter((param) => { - if (!isFirst && param.id === inputParam) return false; - return isParamVisible(param, step.parameters); - }); - - const fieldOptions = (param: AlgorithmParameter): string[] => { - const sourceId = param.fieldSource ?? PRIMARY_INPUT_PARAM; - // A field drawn from the chained input has no resolvable layer on later - // steps (the upstream output is in-memory only), so offer no options there. - if (!isFirst && sourceId === inputParam) return []; - const layerId = step.parameters[sourceId] as string | undefined; - return (layerId && fieldsByLayer.get(layerId)) || []; - }; - - return ( -
-
- -
- - - -
-
- - {!isFirst ? ( -

- {t("processing.modelBuilder.inputPreviousStep")} -

- ) : null} - - {!tool ? ( -

- {t("processing.modelBuilder.unknownTool", { id: step.toolId })} -

- ) : visibleParams.length === 0 ? ( -

{t("processing.modelBuilder.noParameters")}

- ) : ( -
- {visibleParams.map((param) => ( - onParamChange(param.id, value)} - /> - ))} -
- )} -
- ); -} diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx new file mode 100644 index 0000000000..df4e7ab073 --- /dev/null +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -0,0 +1,1303 @@ +import { + DEFAULT_LAYER_STYLE, + useAppStore, + type GeoLibreLayer, + type ModelGraphNode, + type ProcessingModel, + type ProcessingModelGraph, +} from "@geolibre/core"; +import type { MapController } from "@geolibre/map"; +import { + VECTOR_TOOLS, + fetchRemoteWhiteboxCatalogSnapshot, + getVectorTool, + listWasmToolManifests, + mergeWasmToolManifests, + runAlgorithmCapture, + runModelGraph, + runWhiteboxToolWasm, + validateModelGraph, + graphToLinearSteps, + type ModelGraphIssue, + type ModelToolDescriptor, + type ModelValue, + type WhiteboxLayerInput, +} from "@geolibre/processing"; +import { Button, Input, Label, ScrollArea, Select, cn } from "@geolibre/ui"; +import { Download, GripVertical, Loader2, Play, Plus, Save, Trash2, Upload, X } from "lucide-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type DragEvent as ReactDragEvent, + type PointerEvent as ReactPointerEvent, + type ReactElement, +} from "react"; +import { useTranslation } from "react-i18next"; +import { clamp } from "../../../lib/clamp"; +import { createDuckDbCapability } from "../../../lib/duckdb-processing"; +import { + buildModelToolCatalog, + groupModelTools, + searchModelTools, +} from "../../../lib/model-tool-catalog"; +import { + NODE_HEIGHT, + NODE_WIDTH, + addDataNode, + addToolNode, + autoLayout, + connectNodes, + emptyModelGraph, + moveNode, + removeEdge, + removeNode, + setNodeField, + setNodeParameter, +} from "../../../lib/model-graph-edit"; +import { ParameterField } from "../ParameterField"; + +/** MIME type carrying a palette tool key through an HTML5 drag. */ +const TOOL_DRAG_TYPE = "application/x-geolibre-model-tool"; + +const MIN_WIDTH = 820; +const MIN_HEIGHT = 420; +const EDGE_MARGIN = 12; + +/** A best-effort unique id (the webview always has crypto.randomUUID). */ +function createId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `id-${Math.random().toString(36).slice(2)}`; +} + +/** Where a port's connector dot sits, in canvas coordinates. */ +function portPosition( + node: ModelGraphNode, + index: number, + count: number, + side: "in" | "out", +): { x: number; y: number } { + const x = side === "in" ? node.x : node.x + NODE_WIDTH; + const spacing = NODE_HEIGHT / (count + 1); + return { x, y: node.y + spacing * (index + 1) }; +} + +interface ModelBuilderPanelProps { + mapControllerRef: React.RefObject; + /** Adds a raster result (COG bytes) to the map, when the host supports it. */ + onAddRaster?: (bytes: Uint8Array, name: string, fileName: string) => Promise | void; +} + +/** + * ArcGIS-ModelBuilder-style canvas: drag tools from the palette onto the canvas, + * wire their ports together, and run the resulting graph. Lives in a floating, + * resizable panel over the map so the user can see their layers while building. + */ +export function ModelBuilderPanel({ + mapControllerRef, + onAddRaster, +}: ModelBuilderPanelProps): ReactElement | null { + const { t } = useTranslation(); + const open = useAppStore((s) => s.ui.modelBuilderOpen); + const setOpen = useAppStore((s) => s.setModelBuilderOpen); + const layers = useAppStore((s) => s.layers); + const savedModels = useAppStore((s) => s.models); + const saveModel = useAppStore((s) => s.saveModel); + const addGeoJsonLayer = useAppStore((s) => s.addGeoJsonLayer); + + const [position, setPosition] = useState({ x: 48, y: 48 }); + const [size, setSize] = useState({ width: 980, height: 560 }); + const [modelId, setModelId] = useState(() => createId()); + const [modelName, setModelName] = useState(""); + const [graph, setGraph] = useState(emptyModelGraph); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [catalog, setCatalog] = useState([]); + const [search, setSearch] = useState(""); + const [log, setLog] = useState([]); + const [running, setRunning] = useState(false); + const [nodeStatus, setNodeStatus] = useState>({}); + const abortRef = useRef(null); + const sectionRef = useRef(null); + const canvasRef = useRef(null); + const importRef = useRef(null); + + const appendLog = useCallback((line: string) => setLog((prev) => [...prev, line]), []); + + // The default size assumes a full-width map; open side panels can leave much + // less, which would push the inspector column out of view. Shrink to whatever + // the map area actually offers the first time the panel is shown. + useLayoutEffect(() => { + if (!open) return; + const bounds = sectionRef.current?.parentElement?.getBoundingClientRect(); + if (!bounds) return; + setSize((current) => ({ + width: clamp(current.width, MIN_WIDTH, Math.max(MIN_WIDTH, bounds.width - EDGE_MARGIN * 2)), + height: clamp( + current.height, + MIN_HEIGHT, + Math.max(MIN_HEIGHT, bounds.height - EDGE_MARGIN * 2), + ), + })); + setPosition((current) => ({ + x: clamp(current.x, 0, Math.max(0, bounds.width - MIN_WIDTH - EDGE_MARGIN)), + y: clamp(current.y, 0, Math.max(0, bounds.height - MIN_HEIGHT - EDGE_MARGIN)), + })); + }, [open]); + + // Load both registries once the panel is first opened. The Whitebox catalog is + // a fetched snapshot and the WASM manifests load the binary, so they run + // concurrently and each degrades independently: losing one still leaves a + // usable palette built from the other. + useEffect(() => { + if (!open || catalog.length > 0) return; + let cancelled = false; + void (async () => { + const [catalogResult, wasmResult] = await Promise.allSettled([ + fetchRemoteWhiteboxCatalogSnapshot(), + listWasmToolManifests(), + ]); + if (cancelled) return; + const catalogTools = catalogResult.status === "fulfilled" ? catalogResult.value : []; + const wasmTools = wasmResult.status === "fulfilled" ? wasmResult.value : []; + if (catalogResult.status === "rejected") { + console.warn( + "[GeoLibre] Model Builder could not load the Whitebox catalog:", + catalogResult.reason, + ); + } + if (wasmResult.status === "rejected") { + console.warn( + "[GeoLibre] Model Builder could not enumerate WASM manifests:", + wasmResult.reason, + ); + } + setCatalog( + buildModelToolCatalog(VECTOR_TOOLS, mergeWasmToolManifests(catalogTools, wasmTools)), + ); + })(); + return () => { + cancelled = true; + }; + }, [open, catalog.length]); + + const descriptorByKey = useMemo( + () => new Map(catalog.map((descriptor) => [descriptor.key, descriptor])), + [catalog], + ); + const resolveDescriptor = useCallback( + (provider: string | undefined, toolId: string | undefined) => + provider && toolId ? descriptorByKey.get(`${provider}:${toolId}`) : undefined, + [descriptorByKey], + ); + + const issues = useMemo( + // An untouched canvas is not a broken model, and the palette has to be + // loaded before an unknown-tool verdict means anything. + () => + catalog.length && graph.nodes.length ? validateModelGraph(graph, resolveDescriptor) : [], + [graph, resolveDescriptor, catalog.length], + ); + const issuesByNode = useMemo(() => { + const map = new Map(); + for (const issue of issues) { + if (!issue.nodeId) continue; + const list = map.get(issue.nodeId) ?? []; + list.push(issue); + map.set(issue.nodeId, list); + } + return map; + }, [issues]); + + const selectedNode = graph.nodes.find((node) => node.id === selectedNodeId) ?? null; + const filtered = useMemo(() => searchModelTools(catalog, search), [catalog, search]); + const groups = useMemo(() => groupModelTools(filtered), [filtered]); + + const resetRunState = useCallback(() => { + setNodeStatus({}); + setLog([]); + }, []); + + const handleNewModel = useCallback(() => { + setModelId(createId()); + setModelName(""); + setGraph(emptyModelGraph()); + setSelectedNodeId(null); + resetRunState(); + }, [resetRunState]); + + const handleLoadModel = useCallback( + (model: ProcessingModel) => { + setModelId(model.id); + setModelName(model.name); + setGraph(autoLayout(model.graph ?? stepsToGraph(model))); + setSelectedNodeId(null); + resetRunState(); + }, + [resetRunState], + ); + + const handleSave = useCallback(() => { + // Also write the legacy linear projection when the graph happens to be a + // single vector chain, so a build without the canvas can still run it. + saveModel({ + id: modelId, + name: modelName.trim() || t("processing.modelBuilder.untitledModel"), + steps: graphToLinearSteps(graph), + graph, + }); + appendLog(t("processing.modelBuilder.savedLog")); + }, [saveModel, modelId, modelName, graph, appendLog, t]); + + const handleExport = useCallback(() => { + const json = JSON.stringify({ name: modelName, graph }, null, 2); + const url = URL.createObjectURL(new Blob([json], { type: "application/json" })); + const anchor = document.createElement("a"); + const slug = modelName + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-|-$/g, ""); + anchor.href = url; + anchor.download = `${slug || "model"}.model.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Defer revoke so the browser can fetch the blob first (Firefox races and + // silently drops the download if the URL is revoked synchronously). + setTimeout(() => URL.revokeObjectURL(url), 0); + appendLog(t("processing.modelBuilder.exportedLog", { name: anchor.download })); + }, [modelName, graph, appendLog, t]); + + const handleImport = useCallback( + async (file: File) => { + try { + const parsed = JSON.parse(await file.text()) as { + name?: unknown; + graph?: ProcessingModelGraph; + }; + if (!parsed.graph || !Array.isArray(parsed.graph.nodes)) { + throw new Error(t("processing.modelBuilder.importInvalid")); + } + setModelName(typeof parsed.name === "string" ? parsed.name : ""); + setGraph(autoLayout(parsed.graph)); + setSelectedNodeId(null); + resetRunState(); + appendLog(t("processing.modelBuilder.importedLog", { nodes: parsed.graph.nodes.length })); + } catch (err) { + appendLog(`${t("processing.modelBuilder.importFailed")}: ${(err as Error).message}`); + } + }, + [appendLog, resetRunState, t], + ); + + // --- Canvas interaction ------------------------------------------------- + + const canvasPoint = useCallback((clientX: number, clientY: number) => { + const rect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = canvasRef.current?.scrollLeft ?? 0; + const scrollTop = canvasRef.current?.scrollTop ?? 0; + return { + x: clientX - (rect?.left ?? 0) + scrollLeft, + y: clientY - (rect?.top ?? 0) + scrollTop, + }; + }, []); + + const handleCanvasDrop = useCallback( + (event: ReactDragEvent) => { + event.preventDefault(); + const key = event.dataTransfer.getData(TOOL_DRAG_TYPE); + const descriptor = descriptorByKey.get(key); + if (!descriptor) return; + const point = canvasPoint(event.clientX, event.clientY); + const next = addToolNode( + graph, + descriptor, + { x: Math.max(0, point.x - NODE_WIDTH / 2), y: Math.max(0, point.y - NODE_HEIGHT / 2) }, + createId, + ); + setGraph(next.graph); + setSelectedNodeId(next.nodeId); + }, + [descriptorByKey, graph, canvasPoint], + ); + + const addNode = useCallback( + (kind: "input" | "output") => { + // Inputs go in the first column and outputs in the second; the placement + // helper pushes a new card down until it has a clear footprint. Both stay + // near the origin so they land in view even in a narrow panel, rather + // than off the right edge where the user would have to scroll to find + // them. + const next = addDataNode( + graph, + kind, + { x: kind === "input" ? 24 : 24 + NODE_WIDTH + 72, y: 24 }, + createId, + ); + setGraph(next.graph); + setSelectedNodeId(next.nodeId); + }, + [graph], + ); + + // Node dragging. + const handleNodePointerDown = useCallback( + (event: ReactPointerEvent, node: ModelGraphNode) => { + if ((event.target as HTMLElement).closest("[data-port]")) return; + event.preventDefault(); + setSelectedNodeId(node.id); + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const startX = event.clientX; + const startY = event.clientY; + const origin = { x: node.x, y: node.y }; + const handleMove = (move: PointerEvent) => { + setGraph((current) => + moveNode(current, node.id, { + x: Math.max(0, origin.x + (move.clientX - startX)), + y: Math.max(0, origin.y + (move.clientY - startY)), + }), + ); + }; + const handleEnd = () => { + if (handle.hasPointerCapture(event.pointerId)) + handle.releasePointerCapture(event.pointerId); + handle.removeEventListener("pointermove", handleMove); + handle.removeEventListener("pointerup", handleEnd); + handle.removeEventListener("pointercancel", handleEnd); + }; + handle.addEventListener("pointermove", handleMove); + handle.addEventListener("pointerup", handleEnd); + handle.addEventListener("pointercancel", handleEnd); + }, + [], + ); + + // Port-to-port wiring. + const [linking, setLinking] = useState<{ + nodeId: string; + portId: string; + x: number; + y: number; + } | null>(null); + + const handlePortPointerDown = useCallback( + (event: ReactPointerEvent, nodeId: string, portId: string) => { + event.preventDefault(); + event.stopPropagation(); + const start = canvasPoint(event.clientX, event.clientY); + setLinking({ nodeId, portId, x: start.x, y: start.y }); + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const handleMove = (move: PointerEvent) => { + const point = canvasPoint(move.clientX, move.clientY); + setLinking((current) => (current ? { ...current, x: point.x, y: point.y } : current)); + }; + const handleEnd = (end: PointerEvent) => { + if (handle.hasPointerCapture(end.pointerId)) handle.releasePointerCapture(end.pointerId); + handle.removeEventListener("pointermove", handleMove); + handle.removeEventListener("pointerup", handleEnd); + handle.removeEventListener("pointercancel", handleEnd); + // Resolve the drop target from the element under the pointer, since the + // pointer is captured and the target port never receives its own event. + const dropped = document + .elementFromPoint(end.clientX, end.clientY) + ?.closest("[data-port='in']"); + const toNode = dropped?.dataset.nodeId; + const toPort = dropped?.dataset.portId; + if (toNode && toPort) { + setGraph((current) => { + const result = connectNodes( + current, + { nodeId, portId }, + { nodeId: toNode, portId: toPort }, + createId, + ); + if ("rejected" in result) { + appendLog( + result.rejected === "cycle" + ? t("processing.modelBuilder.connectCycle") + : t("processing.modelBuilder.connectSameNode"), + ); + return current; + } + return result.graph; + }); + } + setLinking(null); + }; + handle.addEventListener("pointermove", handleMove); + handle.addEventListener("pointerup", handleEnd); + handle.addEventListener("pointercancel", handleEnd); + }, + [canvasPoint, appendLog, t], + ); + + // --- Running ------------------------------------------------------------ + + const handleRun = useCallback(async () => { + if (issues.length > 0) { + appendLog(t("processing.modelBuilder.fixIssuesFirst")); + return; + } + const controller = new AbortController(); + abortRef.current = controller; + setRunning(true); + setNodeStatus({}); + const duckdb = createDuckDbCapability(); + try { + const result = await runModelGraph(graph, { + resolveDescriptor, + resolveInput: (layerId) => layerToModelValue(layers, layerId), + emitOutput: (name, value) => { + if (value.kind === "vector") { + addGeoJsonLayer(name, value.geojson); + } else if (onAddRaster) { + void onAddRaster(value.bytes, name, `${name.replace(/\s+/g, "_")}.tif`); + } else { + appendLog(t("processing.modelBuilder.rasterOutputUnsupported", { name })); + } + }, + log: appendLog, + signal: controller.signal, + onNodeStatus: (nodeId, status) => + setNodeStatus((current) => ({ ...current, [nodeId]: status })), + executeTool: async ({ node, descriptor, inputs, signal }) => + executeModelTool({ + node, + descriptor, + inputs, + signal, + layers, + duckdb, + log: appendLog, + }), + }); + appendLog( + result.error + ? `${t("processing.modelBuilder.runFailed")}: ${result.error.message}` + : t("processing.modelBuilder.runFinished", { + outputs: Object.keys(result.outputs).length, + }), + ); + } finally { + setRunning(false); + abortRef.current = null; + } + }, [issues.length, graph, resolveDescriptor, layers, addGeoJsonLayer, onAddRaster, appendLog, t]); + + // --- Panel chrome ------------------------------------------------------- + + const handleDragStart = (event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest("button, input")) return; + event.preventDefault(); + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const card = handle.closest("section") as HTMLElement; + const startX = event.clientX; + const startY = event.clientY; + const origin = position; + const handleMove = (move: PointerEvent) => { + const bounds = card.parentElement?.getBoundingClientRect(); + const maxX = bounds ? bounds.width - card.offsetWidth - EDGE_MARGIN : Infinity; + const maxY = bounds ? bounds.height - card.offsetHeight - EDGE_MARGIN : Infinity; + setPosition({ + x: clamp(origin.x + (move.clientX - startX), 0, Math.max(0, maxX)), + y: clamp(origin.y + (move.clientY - startY), 0, Math.max(0, maxY)), + }); + }; + const handleEnd = () => { + if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); + handle.removeEventListener("pointermove", handleMove); + handle.removeEventListener("pointerup", handleEnd); + handle.removeEventListener("pointercancel", handleEnd); + }; + handle.addEventListener("pointermove", handleMove); + handle.addEventListener("pointerup", handleEnd); + handle.addEventListener("pointercancel", handleEnd); + }; + + const handleResizeStart = (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const startX = event.clientX; + const startY = event.clientY; + const start = size; + const handleMove = (move: PointerEvent) => { + const bounds = ( + handle.closest("section") as HTMLElement + )?.parentElement?.getBoundingClientRect(); + const maxWidth = bounds ? bounds.width - position.x - EDGE_MARGIN : Infinity; + const maxHeight = bounds ? bounds.height - position.y - EDGE_MARGIN : Infinity; + setSize({ + width: clamp( + start.width + (move.clientX - startX), + MIN_WIDTH, + Math.max(MIN_WIDTH, maxWidth), + ), + height: clamp( + start.height + (move.clientY - startY), + MIN_HEIGHT, + Math.max(MIN_HEIGHT, maxHeight), + ), + }); + }; + const handleEnd = () => { + if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); + handle.removeEventListener("pointermove", handleMove); + handle.removeEventListener("pointerup", handleEnd); + handle.removeEventListener("pointercancel", handleEnd); + }; + handle.addEventListener("pointermove", handleMove); + handle.addEventListener("pointerup", handleEnd); + handle.addEventListener("pointercancel", handleEnd); + }; + + if (!open) return null; + + const canvasExtent = graph.nodes.reduce( + (acc, node) => ({ + width: Math.max(acc.width, node.x + NODE_WIDTH + 80), + height: Math.max(acc.height, node.y + NODE_HEIGHT + 80), + }), + { width: 640, height: 400 }, + ); + + return ( +
+ {/* Title bar doubles as the drag handle. */} +
+
+ +
+ {/* Palette */} +
+
+ setSearch(event.target.value)} + placeholder={t("processing.modelBuilder.searchTools")} + aria-label={t("processing.modelBuilder.searchTools")} + className="h-7 text-xs" + /> +
+ + +
+
+ + {catalog.length === 0 ? ( +

+ {t("processing.modelBuilder.loadingTools")} +

+ ) : groups.length === 0 ? ( +

+ {t("processing.modelBuilder.noToolsMatch")} +

+ ) : ( + groups.map((group) => ( +
+

+ {group.group} +

+ {group.tools.map((tool) => ( +
{ + event.dataTransfer.setData(TOOL_DRAG_TYPE, tool.key); + event.dataTransfer.effectAllowed = "copy"; + }} + title={tool.description ?? tool.name} + className="cursor-grab truncate rounded px-1.5 py-1 text-xs hover:bg-accent active:cursor-grabbing" + > + {tool.name} +
+ ))} +
+ )) + )} +
+
+ + {/* Canvas */} +
{ + if (event.dataTransfer.types.includes(TOOL_DRAG_TYPE)) { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={handleCanvasDrop} + onClick={(event) => { + if (event.target === event.currentTarget) setSelectedNodeId(null); + }} + > +
+ setGraph((current) => removeEdge(current, edgeId))} + /> + {graph.nodes.map((node) => ( + + ))} +
+ {/* Centred on the visible canvas rather than the scroll extent, which + is wider than the viewport and would push the hint out of sight. */} + {graph.nodes.length === 0 && ( +

+ {t("processing.modelBuilder.canvasEmpty")} +

+ )} +
+ + {/* Inspector */} +
+ + + selectedNode && + setGraph((current) => setNodeField(current, selectedNode.id, field, value)) + } + onParamChange={(paramId, value) => + selectedNode && + setGraph((current) => setNodeParameter(current, selectedNode.id, paramId, value)) + } + onRemove={() => { + if (!selectedNode) return; + setGraph((current) => removeNode(current, selectedNode.id)); + setSelectedNodeId(null); + }} + /> + + {savedModels.length > 0 && ( +
+ + +
+ )} +
+
+ + {/* Issues + log */} +
+ + {issues.map((issue, index) => ( +
+ {issue.message} +
+ ))} + {log.map((line, index) => ( +
+ {line} +
+ ))} + {issues.length === 0 && log.length === 0 && ( + + {t("processing.modelBuilder.outputPlaceholder")} + + )} +
+
+ + {/* Resize grip */} +
+
+ ); +} + +/** SVG layer drawing every connection, plus the in-progress link. */ +function GraphEdges({ + graph, + resolveDescriptor, + linking, + onRemoveEdge, +}: { + graph: ProcessingModelGraph; + resolveDescriptor: (provider?: string, toolId?: string) => ModelToolDescriptor | undefined; + linking: { nodeId: string; portId: string; x: number; y: number } | null; + onRemoveEdge: (edgeId: string) => void; +}): ReactElement { + const { t } = useTranslation(); + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + + const anchor = (nodeId: string, portId: string, side: "in" | "out") => { + const node = byId.get(nodeId); + if (!node) return null; + const ports = portsOf(node, resolveDescriptor(node.provider, node.toolId)); + const list = side === "in" ? ports.inputs : ports.outputs; + const index = list.findIndex((port) => port.id === portId); + if (index < 0) return null; + return portPosition(node, index, list.length, side); + }; + + return ( + + ); +} + +/** The ports a node exposes, mirroring the graph engine's own rule. */ +function portsOf( + node: ModelGraphNode, + descriptor: ModelToolDescriptor | undefined, +): { inputs: { id: string; label: string }[]; outputs: { id: string; label: string }[] } { + if (node.kind === "input") return { inputs: [], outputs: [{ id: "out", label: "Output" }] }; + if (node.kind === "output") return { inputs: [{ id: "in", label: "Input" }], outputs: [] }; + return { inputs: descriptor?.inputs ?? [], outputs: descriptor?.outputs ?? [] }; +} + +/** One draggable card on the canvas. */ +function GraphNodeCard({ + node, + descriptor, + layers, + selected, + status, + hasIssue, + onPointerDown, + onPortPointerDown, +}: { + node: ModelGraphNode; + descriptor: ModelToolDescriptor | undefined; + layers: GeoLibreLayer[]; + selected: boolean; + status?: "running" | "done" | "error"; + hasIssue: boolean; + onPointerDown: (event: ReactPointerEvent, node: ModelGraphNode) => void; + onPortPointerDown: ( + event: ReactPointerEvent, + nodeId: string, + portId: string, + ) => void; +}): ReactElement { + const { t } = useTranslation(); + const ports = portsOf(node, descriptor); + const title = + node.kind === "input" + ? (layers.find((layer) => layer.id === node.layerId)?.name ?? + t("processing.modelBuilder.inputNode")) + : node.kind === "output" + ? node.name?.trim() || t("processing.modelBuilder.outputNode") + : (descriptor?.name ?? node.toolId ?? ""); + + return ( +
onPointerDown(event, node)} + style={{ left: node.x, top: node.y, width: NODE_WIDTH, height: NODE_HEIGHT }} + className={cn( + "absolute cursor-grab select-none rounded-md border bg-card p-2 shadow-sm active:cursor-grabbing", + selected && "border-primary ring-2 ring-primary/30", + hasIssue && !selected && "border-destructive", + status === "running" && "ring-2 ring-primary", + status === "done" && "border-primary/60", + status === "error" && "border-destructive ring-2 ring-destructive/40", + )} + > +

+ {node.kind === "tool" + ? (node.provider ?? "") + : node.kind === "input" + ? t("processing.modelBuilder.inputNode") + : t("processing.modelBuilder.outputNode")} +

+

+ {title} +

+ + {ports.inputs.map((port, index) => { + const at = portPosition(node, index, ports.inputs.length, "in"); + return ( +
+ ); +} + +/** Right-hand properties panel for whichever node is selected. */ +function NodeInspector({ + node, + descriptor, + layers, + issues, + onFieldChange, + onParamChange, + onRemove, +}: { + node: ModelGraphNode | null; + descriptor: ModelToolDescriptor | undefined; + layers: GeoLibreLayer[]; + issues: ModelGraphIssue[]; + onFieldChange: (field: "layerId" | "name", value: string) => void; + onParamChange: (paramId: string, value: unknown) => void; + onRemove: () => void; +}): ReactElement { + const { t } = useTranslation(); + if (!node) { + return ( +

+ {t("processing.modelBuilder.selectNodeHint")} +

+ ); + } + + return ( +
+
+

+ {node.kind === "tool" + ? (descriptor?.name ?? node.toolId) + : node.kind === "input" + ? t("processing.modelBuilder.inputNode") + : t("processing.modelBuilder.outputNode")} +

+ +
+ + {issues.map((issue, index) => ( +

+ {issue.message} +

+ ))} + + {node.kind === "input" && ( +
+ + +
+ )} + + {node.kind === "output" && ( +
+ + onFieldChange("name", event.target.value)} + placeholder={t("processing.modelBuilder.resultNamePlaceholder")} + /> +
+ )} + + {node.kind === "tool" && descriptor && ( +
+ {descriptor.description && ( +

{descriptor.description}

+ )} + {descriptor.parameters.length === 0 ? ( +

+ {t("processing.modelBuilder.noParameters")} +

+ ) : ( + descriptor.parameters.map((param) => ( + ({ id: layer.id, name: layer.name }))} + onChange={(value) => onParamChange(param.id, value)} + /> + )) + )} +
+ )} +
+ ); +} + +/** Rebuild a graph from a legacy linear model, so old saves open on the canvas. */ +function stepsToGraph(model: ProcessingModel): ProcessingModelGraph { + const nodes: ModelGraphNode[] = []; + const edges: ProcessingModelGraph["edges"] = []; + let previousId: string | null = null; + model.steps.forEach((step, index) => { + nodes.push({ + id: step.id, + kind: "tool", + x: 40 + index * 240, + y: 40, + provider: "vector", + toolId: step.toolId, + parameters: { ...step.parameters }, + }); + if (previousId) { + edges.push({ + id: `${previousId}-${step.id}`, + from: previousId, + fromPort: "out", + to: step.id, + toPort: step.inputParam ?? "layer", + }); + } + previousId = step.id; + }); + if (previousId) { + const outputId = `${previousId}-output`; + nodes.push({ + id: outputId, + kind: "output", + x: 40 + model.steps.length * 240, + y: 40, + name: model.name, + }); + edges.push({ + id: `${previousId}-to-output`, + from: previousId, + fromPort: "out", + to: outputId, + toPort: "in", + }); + } + return { nodes, edges }; +} + +/** Wrap a project layer as a model value the graph runner can carry. */ +function layerToModelValue(layers: GeoLibreLayer[], layerId: string): ModelValue | null { + const layer = layers.find((entry) => entry.id === layerId); + if (!layer) return null; + if (layer.geojson) return { kind: "vector", geojson: layer.geojson }; + return null; +} + +/** + * Run one tool node, dispatching to whichever engine owns it. + * + * Client vector tools take a synthetic in-memory layer per wired input, exactly + * as the linear runner does. Whitebox tools take their inputs as + * `layer_inputs` — GeoJSON for a `vector_in`, raw GeoTIFF bytes for a + * `raster_in` — and their job outputs are mapped back onto the descriptor's + * output ports so the next node receives the right payload. + */ +async function executeModelTool({ + node, + descriptor, + inputs, + signal, + layers, + duckdb, + log, +}: { + node: ModelGraphNode; + descriptor: ModelToolDescriptor; + inputs: Record; + signal?: AbortSignal; + layers: GeoLibreLayer[]; + duckdb: ReturnType; + log: (message: string) => void; +}): Promise> { + if (descriptor.provider === "vector") { + const tool = getVectorTool(descriptor.toolId); + if (!tool) throw new Error(`Unknown vector tool "${descriptor.toolId}"`); + // Each wired input becomes a synthetic layer the tool resolves by id, the + // same trick the linear runner uses to chain a step's output forward. + const synthetic: GeoLibreLayer[] = []; + const parameters = { ...(node.parameters ?? {}) }; + for (const [portId, value] of Object.entries(inputs)) { + if (value.kind !== "vector") { + throw new Error(`"${portId}" needs vector data, but a raster arrived.`); + } + const syntheticId = `__geolibre_model_${node.id}_${portId}`; + synthetic.push(syntheticLayer(syntheticId, portId, value.geojson)); + parameters[portId] = syntheticId; + } + const output = await runAlgorithmCapture(tool, parameters, { + layers: [...layers, ...synthetic], + log, + duckdb, + signal, + }); + if (!output) throw new Error(`"${descriptor.name}" produced no output.`); + return { out: { kind: "vector", geojson: output } }; + } + + const layerInputs: Record = {}; + for (const [portId, value] of Object.entries(inputs)) { + layerInputs[portId] = + value.kind === "vector" + ? { name: portId, kind: "vector_in", geojson: value.geojson } + : { name: portId, kind: "raster_in", bytes: value.bytes }; + } + const job = await runWhiteboxToolWasm({ + tool_id: descriptor.toolId, + parameters: { ...(node.parameters ?? {}) }, + layer_inputs: layerInputs, + include_pro: false, + tier: "open", + }); + if (job.error) throw new Error(job.error); + for (const message of job.messages ?? []) log(message); + + const results: Record = {}; + for (const port of descriptor.outputs) { + const value = job.outputs?.[port.id]; + if (value instanceof Uint8Array) { + results[port.id] = { kind: "raster", bytes: value, name: port.id }; + } else if ( + value && + typeof value === "object" && + (value as { type?: string }).type === "FeatureCollection" + ) { + results[port.id] = { + kind: "vector", + geojson: value as ModelValue extends { kind: "vector"; geojson: infer G } ? G : never, + }; + } + } + if (Object.keys(results).length === 0) { + throw new Error(`"${descriptor.name}" produced no usable output.`); + } + return results; +} + +/** A throwaway in-memory layer wrapping one wired input for a client tool. */ +function syntheticLayer( + id: string, + name: string, + geojson: NonNullable, +): GeoLibreLayer { + return { + id, + name, + type: "geojson", + source: { type: "geojson" }, + visible: true, + opacity: 1, + style: { ...DEFAULT_LAYER_STYLE }, + metadata: {}, + geojson, + }; +} diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 7b4b99d178..68d0c82e6f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -2722,7 +2722,7 @@ "duckdbLayer": "طبقة DuckDB", "whitebox": "Whitebox", "geocode": "الترميز الجغرافي للعناوين", - "modelBuilder": "الدفعات والنماذج", + "modelBuilder": "منشئ النماذج", "processingHistory": "السجل", "conversion": "التحويل", "vector": "المتجهات", @@ -2901,7 +2901,8 @@ "projectName": "اسم المشروع", "storymapEllipsis": "خريطة قصصية...", "pointerElevationNoticeTitle": "الارتفاع يستخدم خدمة ارتفاعات عامة", - "pointerElevationNoticeDesc": "يُحسب الارتفاع من تضاريس الخريطة ثلاثية الأبعاد عند تفعيلها دون إرسال أي بيانات. وبدونها تُستخدم واجهة Open-Meteo العامة، فتغادر إحداثيات المؤشر جهازك." + "pointerElevationNoticeDesc": "يُحسب الارتفاع من تضاريس الخريطة ثلاثية الأبعاد عند تفعيلها دون إرسال أي بيانات. وبدونها تُستخدم واجهة Open-Meteo العامة، فتغادر إحداثيات المؤشر جهازك.", + "batchTools": "أدوات الدفعات" }, "plugin": { "maplibre-gl-annotations": "التعليقات التوضيحية", @@ -4288,38 +4289,61 @@ "count_other": "تم تسجيل {{count}} عملية تشغيل", "toolUnavailable": "الأداة «{{toolId}}» لم تعد متوفرة" }, - "modelBuilder": { - "moveStepUp": "نقل الخطوة لأعلى", - "moveStepDown": "نقل الخطوة لأسفل", - "removeStep": "إزالة الخطوة", - "title": "الدُفعات والنماذج", - "description": "شغّل أداة متجهات على عدة طبقات، أو اربط الأدوات معًا في نموذج قابل لإعادة الاستخدام يُحفَظ مع مشروعك.", - "tabBatch": "دفعة", - "tabModels": "النماذج", - "outputPlaceholder": "سيظهر الناتج هنا.", + "batchTools": { + "title": "أدوات الدفعات", + "description": "تشغيل أداة متجهية واحدة على عدة طبقات دفعة واحدة.", "tool": "الأداة", "sharedParameters": "المعاملات المشتركة", "noExtraParameters": "لا تحتوي هذه الأداة على معاملات إضافية.", "inputLayers": "طبقات الإدخال", - "selectAll": "تحديد الكل", "clearSelection": "مسح", + "selectAll": "تحديد الكل", "noCompatibleLayers": "لا توجد طبقات GeoJSON متوافقة.", - "newModel": "نموذج جديد", - "noSavedModels": "لا توجد نماذج محفوظة بعد.", - "untitledModel": "نموذج بلا عنوان", + "outputPlaceholder": "سيظهر الناتج هنا." + }, + "modelBuilder": { + "title": "منشئ النماذج", + "description": "اسحب الأدوات إلى لوحة الرسم واربطها معًا في نموذج معالجة.", "modelName": "اسم النموذج", - "emptyPipelineHint": "أضف خطوة لبدء بناء سلسلة المعالجة. تقرأ الخطوة الأولى طبقة إدخال، وتتلقى كل خطوة تالية ناتج الخطوة السابقة.", - "addStep": "إضافة خطوة", - "runModel": "تشغيل النموذج", - "deleteModel": "حذف", - "canvas": "لوحة سير العمل المكاني", - "importPipeline": "استيراد خط الأنابيب", - "exportPipeline": "تصدير خط الأنابيب", - "stepKindTransform": "تحويل", - "canvasEmpty": "لا توجد خطوات بعد — أضف خطوة لبناء سير العمل.", - "inputPreviousStep": "الإدخال: → ناتج الخطوة السابقة", - "unknownTool": "أداة غير معروفة \"{{id}}\"", - "noParameters": "لا توجد معاملات." + "modelNamePlaceholder": "نموذج بلا عنوان", + "untitledModel": "نموذج بلا عنوان", + "newModel": "جديد", + "runModel": "تشغيل", + "importModel": "استيراد", + "exportModel": "تصدير", + "savedModels": "النماذج المحفوظة", + "loadModelPlaceholder": "تحميل نموذج محفوظ...", + "searchTools": "البحث عن الأدوات", + "loadingTools": "جارٍ تحميل الأدوات...", + "noToolsMatch": "لا توجد أدوات تطابق بحثك.", + "addInputNode": "+ مدخل", + "addOutputNode": "+ مخرج", + "canvasEmpty": "اسحب أداة من لوحة الأدوات لبدء البناء.", + "inputNode": "مدخل", + "outputNode": "مخرج", + "inputPort": "مدخل: {{port}}", + "outputPort": "مخرج: {{port}}", + "removeConnection": "إزالة الاتصال", + "removeNode": "إزالة العقدة", + "resizePanel": "تغيير حجم اللوحة", + "selectNodeHint": "اختر عقدة لتحرير إعداداتها.", + "sourceLayer": "طبقة المصدر", + "chooseLayer": "اختر طبقة...", + "resultName": "اسم النتيجة", + "resultNamePlaceholder": "مخرج النموذج", + "noParameters": "لا توجد معاملات.", + "outputPlaceholder": "تظهر الرسائل هنا.", + "connectCycle": "هذا الاتصال سينشئ حلقة مغلقة.", + "connectSameNode": "لا يمكن ربط العقدة بنفسها.", + "fixIssuesFirst": "أصلح المشكلات المذكورة قبل التشغيل.", + "runFailed": "فشل التشغيل", + "runFinished": "انتهى التشغيل — تمت إضافة {{outputs}} مخرج.", + "savedLog": "تم حفظ النموذج في المشروع.", + "exportedLog": "تم تصدير {{name}}", + "importedLog": "تم استيراد نموذج يحتوي على {{nodes}} عقدة.", + "importFailed": "فشل الاستيراد", + "importInvalid": "هذا الملف لا يحتوي على مخطط نموذج.", + "rasterOutputUnsupported": "«{{name}}» نتيجة راستر لا يمكن لهذه النسخة إضافتها إلى الخريطة." }, "parameterField": { "selectLayer": "حدد طبقة...", @@ -5801,7 +5825,8 @@ "pythonConsole": "طرفية Python", "sqlWorkspace": "مساحة عمل SQL", "assistant": "المساعد", - "statusBar": "شريط الحالة" + "statusBar": "شريط الحالة", + "modelBuilder": "منشئ النماذج" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index d44dd91e94..236a519ee3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "DuckDB-Ebene", "whitebox": "Whitebox", "geocode": "Adressen geokodieren", - "modelBuilder": "Stapel & Modelle", + "modelBuilder": "Modellbaukasten", "processingHistory": "Verlauf", "conversion": "Konvertierung", "vector": "Vektor", @@ -2710,7 +2710,8 @@ "projectName": "Projektname", "storymapEllipsis": "Story Map...", "pointerElevationNoticeTitle": "Höhe nutzt einen öffentlichen Höhendienst", - "pointerElevationNoticeDesc": "Die Höhenanzeige wird aus dem 3D-Gelände der Karte berechnet, wenn dieses aktiv ist – dabei werden keine Daten gesendet. Ohne 3D-Gelände wird die öffentliche Open-Meteo-API abgefragt, und die Koordinaten unter dem Zeiger verlassen Ihr Gerät." + "pointerElevationNoticeDesc": "Die Höhenanzeige wird aus dem 3D-Gelände der Karte berechnet, wenn dieses aktiv ist – dabei werden keine Daten gesendet. Ohne 3D-Gelände wird die öffentliche Open-Meteo-API abgefragt, und die Koordinaten unter dem Zeiger verlassen Ihr Gerät.", + "batchTools": "Stapelwerkzeuge" }, "plugin": { "maplibre-gl-annotations": "Anmerkungen", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} Läufe aufgezeichnet", "toolUnavailable": "Werkzeug „{{toolId}}“ ist nicht mehr verfügbar" }, - "modelBuilder": { - "moveStepUp": "Schritt nach oben verschieben", - "moveStepDown": "Schritt nach unten verschieben", - "removeStep": "Schritt entfernen", - "title": "Stapel & Modelle", - "description": "Führen Sie ein Vektorwerkzeug über viele Ebenen aus oder verketten Sie Werkzeuge zu einem wiederverwendbaren, mit Ihrem Projekt gespeicherten Modell.", - "tabBatch": "Stapel", - "tabModels": "Modelle", - "outputPlaceholder": "Die Ausgabe erscheint hier.", + "batchTools": { + "title": "Stapelwerkzeuge", + "description": "Ein Vektorwerkzeug auf viele Ebenen gleichzeitig anwenden.", "tool": "Werkzeug", "sharedParameters": "Gemeinsame Parameter", "noExtraParameters": "Dieses Werkzeug hat keine zusätzlichen Parameter.", "inputLayers": "Eingabeebenen", - "selectAll": "Alle auswählen", "clearSelection": "Leeren", + "selectAll": "Alle auswählen", "noCompatibleLayers": "Keine kompatiblen GeoJSON-Ebenen.", - "newModel": "Neues Modell", - "noSavedModels": "Noch keine gespeicherten Modelle.", - "untitledModel": "Unbenanntes Modell", + "outputPlaceholder": "Die Ausgabe erscheint hier." + }, + "modelBuilder": { + "title": "Modellbaukasten", + "description": "Ziehen Sie Werkzeuge auf die Arbeitsfläche und verbinden Sie sie zu einem Verarbeitungsmodell.", "modelName": "Modellname", - "emptyPipelineHint": "Fügen Sie einen Schritt hinzu, um die Pipeline aufzubauen. Der erste Schritt liest eine Eingabeebene; jeder weitere Schritt erhält die Ausgabe des vorherigen Schritts.", - "addStep": "Schritt hinzufügen", - "runModel": "Modell ausführen", - "deleteModel": "Löschen", - "canvas": "Arbeitsablauf-Canvas", - "importPipeline": "Pipeline importieren", - "exportPipeline": "Pipeline exportieren", - "stepKindTransform": "Transformation", - "canvasEmpty": "Noch keine Schritte — fügen Sie einen hinzu, um den Arbeitsablauf aufzubauen.", - "inputPreviousStep": "Eingabe: ← Ausgabe des vorherigen Schritts", - "unknownTool": "Unbekanntes Werkzeug „{{id}}“", - "noParameters": "Keine Parameter." + "modelNamePlaceholder": "Unbenanntes Modell", + "untitledModel": "Unbenanntes Modell", + "newModel": "Neu", + "runModel": "Ausführen", + "importModel": "Importieren", + "exportModel": "Exportieren", + "savedModels": "Gespeicherte Modelle", + "loadModelPlaceholder": "Gespeichertes Modell laden ...", + "searchTools": "Werkzeuge suchen", + "loadingTools": "Werkzeuge werden geladen ...", + "noToolsMatch": "Keine Werkzeuge entsprechen Ihrer Suche.", + "addInputNode": "+ Eingabe", + "addOutputNode": "+ Ausgabe", + "canvasEmpty": "Ziehen Sie ein Werkzeug aus der Palette, um zu beginnen.", + "inputNode": "Eingabe", + "outputNode": "Ausgabe", + "inputPort": "Eingang: {{port}}", + "outputPort": "Ausgang: {{port}}", + "removeConnection": "Verbindung entfernen", + "removeNode": "Knoten entfernen", + "resizePanel": "Bereichsgröße ändern", + "selectNodeHint": "Wählen Sie einen Knoten, um seine Einstellungen zu bearbeiten.", + "sourceLayer": "Quellebene", + "chooseLayer": "Ebene wählen ...", + "resultName": "Ergebnisname", + "resultNamePlaceholder": "Modellausgabe", + "noParameters": "Keine Parameter.", + "outputPlaceholder": "Meldungen erscheinen hier.", + "connectCycle": "Diese Verbindung würde eine Schleife erzeugen.", + "connectSameNode": "Ein Knoten kann sich nicht mit sich selbst verbinden.", + "fixIssuesFirst": "Beheben Sie die gemeldeten Probleme vor dem Ausführen.", + "runFailed": "Ausführung fehlgeschlagen", + "runFinished": "Ausführung beendet – {{outputs}} Ausgabe(n) hinzugefügt.", + "savedLog": "Modell im Projekt gespeichert.", + "exportedLog": "{{name}} exportiert", + "importedLog": "Modell mit {{nodes}} Knoten importiert.", + "importFailed": "Import fehlgeschlagen", + "importInvalid": "Diese Datei enthält kein Modelldiagramm.", + "rasterOutputUnsupported": "„{{name}}“ ist ein Rasterergebnis, das dieser Build nicht zur Karte hinzufügen kann." }, "parameterField": { "selectLayer": "Ebene auswählen …", @@ -5482,7 +5506,8 @@ "pythonConsole": "Python-Konsole", "sqlWorkspace": "SQL-Arbeitsbereich", "assistant": "Assistent", - "statusBar": "Statusleiste" + "statusBar": "Statusleiste", + "modelBuilder": "Modellbaukasten" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 2e8403d65b..41253ceef1 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -2553,7 +2553,7 @@ "duckdbLayer": "DuckDB Layer", "whitebox": "Whitebox", "geocode": "Geocode Addresses", - "modelBuilder": "Batch & Models", + "modelBuilder": "Model Builder", "processingHistory": "History", "conversion": "Conversion", "vector": "Vector", @@ -2720,7 +2720,8 @@ "projectName": "Project name", "storymapEllipsis": "Story Map...", "pointerElevationNoticeTitle": "Elevation uses a public elevation service", - "pointerElevationNoticeDesc": "The status bar's elevation readout is resolved from the map's own 3D terrain when that is enabled, which sends nothing anywhere. Without 3D terrain it falls back to the public Open-Meteo elevation API, and the coordinates under your pointer leave your device for those requests." + "pointerElevationNoticeDesc": "The status bar's elevation readout is resolved from the map's own 3D terrain when that is enabled, which sends nothing anywhere. Without 3D terrain it falls back to the public Open-Meteo elevation API, and the coordinates under your pointer leave your device for those requests.", + "batchTools": "Batch tools" }, "plugin": { "maplibre-gl-annotations": "Annotations", @@ -4031,38 +4032,61 @@ "count_other": "{{count}} runs recorded", "toolUnavailable": "Tool \"{{toolId}}\" is no longer available" }, - "modelBuilder": { - "moveStepUp": "Move step up", - "moveStepDown": "Move step down", - "removeStep": "Remove step", - "title": "Batch & Models", - "description": "Run a vector tool across many layers, or chain tools into a reusable model saved with your project.", - "tabBatch": "Batch", - "tabModels": "Models", - "outputPlaceholder": "Output will appear here.", + "batchTools": { + "title": "Batch tools", + "description": "Run one vector tool across many layers at once.", "tool": "Tool", "sharedParameters": "Shared parameters", "noExtraParameters": "This tool has no extra parameters.", "inputLayers": "Input layers", - "selectAll": "Select all", "clearSelection": "Clear", + "selectAll": "Select all", "noCompatibleLayers": "No compatible GeoJSON layers.", - "newModel": "New model", - "noSavedModels": "No saved models yet.", - "untitledModel": "Untitled model", + "outputPlaceholder": "Output will appear here." + }, + "modelBuilder": { + "title": "Model Builder", + "description": "Drag tools onto the canvas and connect them into a processing model.", "modelName": "Model name", - "emptyPipelineHint": "Add a step to start building the pipeline. The first step reads an input layer; each later step receives the previous step's output.", - "addStep": "Add step", - "runModel": "Run model", - "deleteModel": "Delete", - "canvas": "Spatial workflow canvas", - "importPipeline": "Import pipeline", - "exportPipeline": "Export pipeline", - "stepKindTransform": "Transform", - "canvasEmpty": "No steps yet — add one to build the workflow.", - "inputPreviousStep": "Input: ← previous step output", - "unknownTool": "Unknown tool \"{{id}}\"", - "noParameters": "No parameters." + "modelNamePlaceholder": "Untitled model", + "untitledModel": "Untitled model", + "newModel": "New", + "runModel": "Run", + "importModel": "Import", + "exportModel": "Export", + "savedModels": "Saved models", + "loadModelPlaceholder": "Load a saved model...", + "searchTools": "Search tools", + "loadingTools": "Loading tools...", + "noToolsMatch": "No tools match your search.", + "addInputNode": "+ Input", + "addOutputNode": "+ Output", + "canvasEmpty": "Drag a tool from the palette to start building.", + "inputNode": "Input", + "outputNode": "Output", + "inputPort": "Input: {{port}}", + "outputPort": "Output: {{port}}", + "removeConnection": "Remove connection", + "removeNode": "Remove node", + "resizePanel": "Resize panel", + "selectNodeHint": "Select a node to edit its settings.", + "sourceLayer": "Source layer", + "chooseLayer": "Choose a layer...", + "resultName": "Result name", + "resultNamePlaceholder": "Model output", + "noParameters": "No parameters.", + "outputPlaceholder": "Messages appear here.", + "connectCycle": "That connection would create a loop.", + "connectSameNode": "A node cannot connect to itself.", + "fixIssuesFirst": "Fix the reported problems before running.", + "runFailed": "Run failed", + "runFinished": "Run finished — {{outputs}} output(s) added.", + "savedLog": "Model saved to the project.", + "exportedLog": "Exported {{name}}", + "importedLog": "Imported a model with {{nodes}} node(s).", + "importFailed": "Import failed", + "importInvalid": "That file does not contain a model graph.", + "rasterOutputUnsupported": "\"{{name}}\" is a raster result, which this build cannot add to the map." }, "parameterField": { "selectLayer": "Select a layer...", @@ -5477,6 +5501,7 @@ "pluginPanelRightOfLayers": "Plugin panel (right of Layers)", "map": "Map", "pluginFloatingPanels": "Plugin floating panels", + "modelBuilder": "Model Builder", "selectionPanels": "Selection panels", "sunSimulationPanel": "Sun simulation panel", "routeAnimationPanel": "Route animation panel", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 390c123758..06dd4d1848 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "Capa DuckDB", "whitebox": "Whitebox", "geocode": "Geocodificar direcciones", - "modelBuilder": "Lotes y modelos", + "modelBuilder": "Constructor de modelos", "processingHistory": "Historial", "conversion": "Conversión", "vector": "Vectorial", @@ -2710,7 +2710,8 @@ "projectName": "Nombre del proyecto", "storymapEllipsis": "Mapa narrativo...", "pointerElevationNoticeTitle": "La elevación se obtiene de un servicio público", - "pointerElevationNoticeDesc": "La elevación se obtiene del relieve 3D del mapa cuando está activo, sin enviar nada. Sin relieve 3D se consulta la API pública de Open-Meteo y las coordenadas bajo el puntero salen de su dispositivo." + "pointerElevationNoticeDesc": "La elevación se obtiene del relieve 3D del mapa cuando está activo, sin enviar nada. Sin relieve 3D se consulta la API pública de Open-Meteo y las coordenadas bajo el puntero salen de su dispositivo.", + "batchTools": "Herramientas por lotes" }, "plugin": { "maplibre-gl-annotations": "Anotaciones", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} ejecuciones registradas", "toolUnavailable": "La herramienta «{{toolId}}» ya no está disponible" }, - "modelBuilder": { - "moveStepUp": "Mover paso arriba", - "moveStepDown": "Mover paso abajo", - "removeStep": "Quitar paso", - "title": "Lotes y modelos", - "description": "Ejecute una herramienta vectorial sobre muchas capas, o encadene herramientas en un modelo reutilizable guardado con su proyecto.", - "tabBatch": "Lote", - "tabModels": "Modelos", - "outputPlaceholder": "La salida aparecerá aquí.", + "batchTools": { + "title": "Herramientas por lotes", + "description": "Ejecutar una herramienta vectorial sobre muchas capas a la vez.", "tool": "Herramienta", "sharedParameters": "Parámetros compartidos", "noExtraParameters": "Esta herramienta no tiene parámetros adicionales.", "inputLayers": "Capas de entrada", - "selectAll": "Seleccionar todo", "clearSelection": "Limpiar", + "selectAll": "Seleccionar todo", "noCompatibleLayers": "No hay capas GeoJSON compatibles.", - "newModel": "Nuevo modelo", - "noSavedModels": "Aún no hay modelos guardados.", - "untitledModel": "Modelo sin título", + "outputPlaceholder": "La salida aparecerá aquí." + }, + "modelBuilder": { + "title": "Constructor de modelos", + "description": "Arrastre herramientas al lienzo y conéctelas para formar un modelo de procesamiento.", "modelName": "Nombre del modelo", - "emptyPipelineHint": "Añada un paso para empezar a construir la cadena. El primer paso lee una capa de entrada; cada paso posterior recibe la salida del paso anterior.", - "addStep": "Añadir paso", - "runModel": "Ejecutar el modelo", - "deleteModel": "Eliminar", - "canvas": "Lienzo de flujo de trabajo espacial", - "importPipeline": "Importar canalización", - "exportPipeline": "Exportar canalización", - "stepKindTransform": "Transformación", - "canvasEmpty": "Aún no hay pasos: añada uno para crear el flujo de trabajo.", - "inputPreviousStep": "Entrada: ← salida del paso anterior", - "unknownTool": "Herramienta desconocida «{{id}}»", - "noParameters": "Sin parámetros." + "modelNamePlaceholder": "Modelo sin título", + "untitledModel": "Modelo sin título", + "newModel": "Nuevo", + "runModel": "Ejecutar", + "importModel": "Importar", + "exportModel": "Exportar", + "savedModels": "Modelos guardados", + "loadModelPlaceholder": "Cargar un modelo guardado...", + "searchTools": "Buscar herramientas", + "loadingTools": "Cargando herramientas...", + "noToolsMatch": "Ninguna herramienta coincide con su búsqueda.", + "addInputNode": "+ Entrada", + "addOutputNode": "+ Salida", + "canvasEmpty": "Arrastre una herramienta desde la paleta para empezar.", + "inputNode": "Entrada", + "outputNode": "Salida", + "inputPort": "Entrada: {{port}}", + "outputPort": "Salida: {{port}}", + "removeConnection": "Quitar conexión", + "removeNode": "Quitar nodo", + "resizePanel": "Cambiar el tamaño del panel", + "selectNodeHint": "Seleccione un nodo para editar su configuración.", + "sourceLayer": "Capa de origen", + "chooseLayer": "Elija una capa...", + "resultName": "Nombre del resultado", + "resultNamePlaceholder": "Salida del modelo", + "noParameters": "Sin parámetros.", + "outputPlaceholder": "Los mensajes aparecen aquí.", + "connectCycle": "Esa conexión crearía un bucle.", + "connectSameNode": "Un nodo no puede conectarse consigo mismo.", + "fixIssuesFirst": "Corrija los problemas indicados antes de ejecutar.", + "runFailed": "La ejecución falló", + "runFinished": "Ejecución terminada: se añadieron {{outputs}} salida(s).", + "savedLog": "Modelo guardado en el proyecto.", + "exportedLog": "{{name}} exportado", + "importedLog": "Se importó un modelo con {{nodes}} nodo(s).", + "importFailed": "La importación falló", + "importInvalid": "Ese archivo no contiene un grafo de modelo.", + "rasterOutputUnsupported": "«{{name}}» es un resultado ráster que esta versión no puede añadir al mapa." }, "parameterField": { "selectLayer": "Seleccionar una capa...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Consola de Python", "sqlWorkspace": "Espacio de trabajo SQL", "assistant": "Asistente", - "statusBar": "Barra de estado" + "statusBar": "Barra de estado", + "modelBuilder": "Constructor de modelos" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 0688778c0b..8aaa8be93c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "لایهٔ DuckDB", "whitebox": "Whitebox", "geocode": "مکان‌یابی نشانی‌ها", - "modelBuilder": "دسته‌ای و مدل‌ها", + "modelBuilder": "سازندهٔ مدل", "processingHistory": "تاریخچه", "conversion": "تبدیل", "vector": "برداری", @@ -2710,7 +2710,8 @@ "projectName": "نام پروژه", "storymapEllipsis": "نقشهٔ روایی...", "pointerElevationNoticeTitle": "ارتفاع از یک سرویس عمومی گرفته می‌شود", - "pointerElevationNoticeDesc": "وقتی زمین سه‌بعدی فعال باشد ارتفاع از خود نقشه محاسبه می‌شود و چیزی ارسال نمی‌گردد. در نبود زمین سه‌بعدی از Open-Meteo عمومی استفاده می‌شود و مختصات زیر نشانگر از دستگاه شما خارج می‌شود." + "pointerElevationNoticeDesc": "وقتی زمین سه‌بعدی فعال باشد ارتفاع از خود نقشه محاسبه می‌شود و چیزی ارسال نمی‌گردد. در نبود زمین سه‌بعدی از Open-Meteo عمومی استفاده می‌شود و مختصات زیر نشانگر از دستگاه شما خارج می‌شود.", + "batchTools": "ابزارهای دسته‌ای" }, "plugin": { "maplibre-gl-annotations": "حاشیه‌نویسی‌ها", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} اجرا ثبت شد", "toolUnavailable": "ابزار «{{toolId}}» دیگر در دسترس نیست" }, - "modelBuilder": { - "moveStepUp": "بردن گام به بالا", - "moveStepDown": "بردن گام به پایین", - "removeStep": "حذف گام", - "title": "دسته‌ای و مدل‌ها", - "description": "یک ابزار برداری را روی چند لایه اجرا کنید، یا ابزارها را به هم زنجیر کنید و به‌صورت مدلی دوباره‌استفاده‌شدنی همراه پروژهٔ خود ذخیره کنید.", - "tabBatch": "دسته‌ای", - "tabModels": "مدل‌ها", - "outputPlaceholder": "خروجی اینجا نمایان می‌شود.", + "batchTools": { + "title": "ابزارهای دسته‌ای", + "description": "اجرای یک ابزار برداری روی چند لایه به‌صورت یکجا.", "tool": "ابزار", "sharedParameters": "پارامترهای مشترک", "noExtraParameters": "این ابزار پارامتر افزوده‌ای ندارد.", "inputLayers": "لایه‌های ورودی", - "selectAll": "انتخاب همه", "clearSelection": "پاک کردن", + "selectAll": "انتخاب همه", "noCompatibleLayers": "هیچ لایهٔ GeoJSON سازگاری نیست.", - "newModel": "مدل جدید", - "noSavedModels": "هنوز مدل ذخیره‌شده‌ای نیست.", - "untitledModel": "مدل بی‌نام", + "outputPlaceholder": "خروجی اینجا نمایان می‌شود." + }, + "modelBuilder": { + "title": "سازندهٔ مدل", + "description": "ابزارها را روی بوم بکشید و آن‌ها را به هم وصل کنید تا یک مدل پردازشی بسازید.", "modelName": "نام مدل", - "emptyPipelineHint": "برای آغاز ساخت خط لوله، یک گام بیفزایید. گام نخست یک لایهٔ ورودی می‌خواند؛ هر گام بعدی خروجی گام پیشین را می‌گیرد.", - "addStep": "افزودن گام", - "runModel": "اجرای مدل", - "deleteModel": "حذف", - "canvas": "بوم گردش کار مکانی", - "importPipeline": "درون‌ریزی خط لوله", - "exportPipeline": "برون‌بری خط لوله", - "stepKindTransform": "تبدیل", - "canvasEmpty": "هنوز گامی وجود ندارد — برای ساخت گردش کار یکی اضافه کنید.", - "inputPreviousStep": "ورودی: → خروجی گام پیشین", - "unknownTool": "ابزار ناشناختهٔ «{{id}}»", - "noParameters": "بدون پارامتر." + "modelNamePlaceholder": "مدل بدون عنوان", + "untitledModel": "مدل بدون عنوان", + "newModel": "جدید", + "runModel": "اجرا", + "importModel": "درون‌ریزی", + "exportModel": "برون‌ریزی", + "savedModels": "مدل‌های ذخیره‌شده", + "loadModelPlaceholder": "بارگذاری یک مدل ذخیره‌شده...", + "searchTools": "جست‌وجوی ابزارها", + "loadingTools": "در حال بارگذاری ابزارها...", + "noToolsMatch": "هیچ ابزاری با جست‌وجوی شما مطابقت ندارد.", + "addInputNode": "+ ورودی", + "addOutputNode": "+ خروجی", + "canvasEmpty": "برای شروع، یک ابزار را از پالت بکشید.", + "inputNode": "ورودی", + "outputNode": "خروجی", + "inputPort": "ورودی: {{port}}", + "outputPort": "خروجی: {{port}}", + "removeConnection": "حذف اتصال", + "removeNode": "حذف گره", + "resizePanel": "تغییر اندازهٔ پنل", + "selectNodeHint": "برای ویرایش تنظیمات، یک گره را انتخاب کنید.", + "sourceLayer": "لایهٔ مبدأ", + "chooseLayer": "یک لایه انتخاب کنید...", + "resultName": "نام نتیجه", + "resultNamePlaceholder": "خروجی مدل", + "noParameters": "پارامتری وجود ندارد.", + "outputPlaceholder": "پیام‌ها اینجا نمایش داده می‌شوند.", + "connectCycle": "این اتصال یک حلقه ایجاد می‌کند.", + "connectSameNode": "یک گره نمی‌تواند به خودش وصل شود.", + "fixIssuesFirst": "پیش از اجرا، مشکلات گزارش‌شده را برطرف کنید.", + "runFailed": "اجرا ناموفق بود", + "runFinished": "اجرا به پایان رسید — {{outputs}} خروجی افزوده شد.", + "savedLog": "مدل در پروژه ذخیره شد.", + "exportedLog": "{{name}} برون‌ریزی شد", + "importedLog": "مدلی با {{nodes}} گره درون‌ریزی شد.", + "importFailed": "درون‌ریزی ناموفق بود", + "importInvalid": "این پرونده شامل گراف مدل نیست.", + "rasterOutputUnsupported": "«{{name}}» یک نتیجهٔ رستری است که این نسخه نمی‌تواند به نقشه بیفزاید." }, "parameterField": { "selectLayer": "یک لایه برگزینید...", @@ -5482,7 +5506,8 @@ "pythonConsole": "کنسول Python", "sqlWorkspace": "فضای کاری SQL", "assistant": "دستیار", - "statusBar": "نوار وضعیت" + "statusBar": "نوار وضعیت", + "modelBuilder": "سازندهٔ مدل" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 747368c10b..b0a01e6aec 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "Couche DuckDB", "whitebox": "Whitebox", "geocode": "Géocoder des adresses", - "modelBuilder": "Lots et modèles", + "modelBuilder": "Générateur de modèles", "processingHistory": "Historique", "conversion": "Conversion", "vector": "Vecteur", @@ -2710,7 +2710,8 @@ "projectName": "Nom du projet", "storymapEllipsis": "Story Map...", "pointerElevationNoticeTitle": "L'altitude est obtenue d'un service public", - "pointerElevationNoticeDesc": "L'altitude est calculée à partir du relief 3D de la carte lorsqu'il est actif, sans rien envoyer. Sans relief 3D, l'API publique Open-Meteo est interrogée et les coordonnées sous le pointeur quittent votre appareil." + "pointerElevationNoticeDesc": "L'altitude est calculée à partir du relief 3D de la carte lorsqu'il est actif, sans rien envoyer. Sans relief 3D, l'API publique Open-Meteo est interrogée et les coordonnées sous le pointeur quittent votre appareil.", + "batchTools": "Outils par lot" }, "plugin": { "maplibre-gl-annotations": "Annotations", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} exécutions enregistrées", "toolUnavailable": "L'outil « {{toolId}} » n'est plus disponible" }, - "modelBuilder": { - "moveStepUp": "Monter l'étape", - "moveStepDown": "Descendre l'étape", - "removeStep": "Supprimer l'étape", - "title": "Lots et modèles", - "description": "Exécutez un outil vecteur sur de nombreuses couches, ou enchaînez des outils dans un modèle réutilisable enregistré avec votre projet.", - "tabBatch": "Lot", - "tabModels": "Modèles", - "outputPlaceholder": "La sortie apparaîtra ici.", + "batchTools": { + "title": "Outils par lot", + "description": "Exécuter un outil vectoriel sur plusieurs couches à la fois.", "tool": "Outil", "sharedParameters": "Paramètres partagés", "noExtraParameters": "Cet outil n'a pas de paramètres supplémentaires.", "inputLayers": "Couches d'entrée", - "selectAll": "Tout sélectionner", "clearSelection": "Effacer", + "selectAll": "Tout sélectionner", "noCompatibleLayers": "Aucune couche GeoJSON compatible.", - "newModel": "Nouveau modèle", - "noSavedModels": "Aucun modèle enregistré pour l'instant.", - "untitledModel": "Modèle sans titre", + "outputPlaceholder": "La sortie apparaîtra ici." + }, + "modelBuilder": { + "title": "Générateur de modèles", + "description": "Faites glisser des outils sur le canevas et reliez-les pour former un modèle de traitement.", "modelName": "Nom du modèle", - "emptyPipelineHint": "Ajoutez une étape pour commencer à construire la chaîne. La première étape lit une couche d'entrée ; chaque étape suivante reçoit la sortie de l'étape précédente.", - "addStep": "Ajouter une étape", - "runModel": "Exécuter le modèle", - "deleteModel": "Supprimer", - "canvas": "Canevas de flux de travail spatial", - "importPipeline": "Importer le pipeline", - "exportPipeline": "Exporter le pipeline", - "stepKindTransform": "Transformation", - "canvasEmpty": "Aucune étape pour l'instant — ajoutez-en une pour créer le flux de travail.", - "inputPreviousStep": "Entrée : ← sortie de l'étape précédente", - "unknownTool": "Outil inconnu « {{id}} »", - "noParameters": "Aucun paramètre." + "modelNamePlaceholder": "Modèle sans titre", + "untitledModel": "Modèle sans titre", + "newModel": "Nouveau", + "runModel": "Exécuter", + "importModel": "Importer", + "exportModel": "Exporter", + "savedModels": "Modèles enregistrés", + "loadModelPlaceholder": "Charger un modèle enregistré...", + "searchTools": "Rechercher des outils", + "loadingTools": "Chargement des outils...", + "noToolsMatch": "Aucun outil ne correspond à votre recherche.", + "addInputNode": "+ Entrée", + "addOutputNode": "+ Sortie", + "canvasEmpty": "Faites glisser un outil depuis la palette pour commencer.", + "inputNode": "Entrée", + "outputNode": "Sortie", + "inputPort": "Entrée : {{port}}", + "outputPort": "Sortie : {{port}}", + "removeConnection": "Supprimer la connexion", + "removeNode": "Supprimer le nœud", + "resizePanel": "Redimensionner le panneau", + "selectNodeHint": "Sélectionnez un nœud pour modifier ses paramètres.", + "sourceLayer": "Couche source", + "chooseLayer": "Choisir une couche...", + "resultName": "Nom du résultat", + "resultNamePlaceholder": "Sortie du modèle", + "noParameters": "Aucun paramètre.", + "outputPlaceholder": "Les messages apparaissent ici.", + "connectCycle": "Cette connexion créerait une boucle.", + "connectSameNode": "Un nœud ne peut pas se connecter à lui-même.", + "fixIssuesFirst": "Corrigez les problèmes signalés avant d'exécuter.", + "runFailed": "Échec de l'exécution", + "runFinished": "Exécution terminée — {{outputs}} sortie(s) ajoutée(s).", + "savedLog": "Modèle enregistré dans le projet.", + "exportedLog": "{{name}} exporté", + "importedLog": "Modèle importé avec {{nodes}} nœud(s).", + "importFailed": "Échec de l'importation", + "importInvalid": "Ce fichier ne contient pas de graphe de modèle.", + "rasterOutputUnsupported": "« {{name}} » est un résultat raster que cette version ne peut pas ajouter à la carte." }, "parameterField": { "selectLayer": "Sélectionner une couche...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Console Python", "sqlWorkspace": "Espace de travail SQL", "assistant": "Assistant", - "statusBar": "Barre d'état" + "statusBar": "Barre d'état", + "modelBuilder": "Générateur de modèles" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 126ea66039..074757a24b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "DuckDB परत", "whitebox": "Whitebox", "geocode": "पते जियोकोड करें", - "modelBuilder": "बैच और मॉडल", + "modelBuilder": "मॉडल बिल्डर", "processingHistory": "इतिहास", "conversion": "रूपांतरण", "vector": "वेक्टर", @@ -2710,7 +2710,8 @@ "projectName": "प्रोजेक्ट नाम", "storymapEllipsis": "स्टोरी मैप...", "pointerElevationNoticeTitle": "ऊँचाई सार्वजनिक सेवा का उपयोग करती है", - "pointerElevationNoticeDesc": "3D भूभाग उपलब्ध होने पर ऊँचाई मानचित्र से ही निकाली जाती है और कुछ भी नहीं भेजा जाता। 3D भूभाग उपलब्ध न होने पर सार्वजनिक Open-Meteo API से ऊँचाई प्राप्त की जाती है और पॉइंटर के नीचे के निर्देशांक आपके डिवाइस से बाहर जाते हैं।" + "pointerElevationNoticeDesc": "3D भूभाग उपलब्ध होने पर ऊँचाई मानचित्र से ही निकाली जाती है और कुछ भी नहीं भेजा जाता। 3D भूभाग उपलब्ध न होने पर सार्वजनिक Open-Meteo API से ऊँचाई प्राप्त की जाती है और पॉइंटर के नीचे के निर्देशांक आपके डिवाइस से बाहर जाते हैं।", + "batchTools": "बैच उपकरण" }, "plugin": { "maplibre-gl-annotations": "एनोटेशन", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} रन दर्ज", "toolUnavailable": "टूल \"{{toolId}}\" अब उपलब्ध नहीं है" }, - "modelBuilder": { - "moveStepUp": "चरण ऊपर ले जाएं", - "moveStepDown": "चरण नीचे ले जाएं", - "removeStep": "चरण हटाएं", - "title": "बैच और मॉडल", - "description": "कई लेयर पर एक वेक्टर टूल चलाएँ, या टूल को जोड़कर एक पुन: प्रयोज्य मॉडल बनाएँ जो आपकी परियोजना के साथ सहेजा जाता है।", - "tabBatch": "बैच", - "tabModels": "मॉडल", - "outputPlaceholder": "आउटपुट यहाँ दिखाई देगा।", + "batchTools": { + "title": "बैच उपकरण", + "description": "एक ही वेक्टर उपकरण को कई परतों पर एक साथ चलाएँ।", "tool": "टूल", "sharedParameters": "साझा पैरामीटर", "noExtraParameters": "इस टूल में कोई अतिरिक्त पैरामीटर नहीं है।", "inputLayers": "इनपुट लेयर", - "selectAll": "सभी चुनें", "clearSelection": "साफ़ करें", + "selectAll": "सभी चुनें", "noCompatibleLayers": "कोई संगत GeoJSON लेयर नहीं।", - "newModel": "नया मॉडल", - "noSavedModels": "अभी तक कोई सहेजा गया मॉडल नहीं।", + "outputPlaceholder": "आउटपुट यहाँ दिखाई देगा।" + }, + "modelBuilder": { + "title": "मॉडल बिल्डर", + "description": "उपकरणों को कैनवास पर खींचें और उन्हें जोड़कर एक प्रोसेसिंग मॉडल बनाएँ।", + "modelName": "मॉडल नाम", + "modelNamePlaceholder": "बिना शीर्षक मॉडल", "untitledModel": "बिना शीर्षक मॉडल", - "modelName": "मॉडल का नाम", - "emptyPipelineHint": "पाइपलाइन बनाना शुरू करने के लिए एक चरण जोड़ें। पहला चरण एक इनपुट लेयर पढ़ता है; उसके बाद हर चरण पिछले चरण का आउटपुट लेता है।", - "addStep": "चरण जोड़ें", - "runModel": "मॉडल चलाएँ", - "deleteModel": "हटाएँ", - "canvas": "स्थानिक वर्कफ़्लो कैनवास", - "importPipeline": "पाइपलाइन आयात करें", - "exportPipeline": "पाइपलाइन निर्यात करें", - "stepKindTransform": "रूपांतरण", - "canvasEmpty": "अभी कोई चरण नहीं — वर्कफ़्लो बनाने के लिए एक जोड़ें।", - "inputPreviousStep": "इनपुट: ← पिछले चरण का आउटपुट", - "unknownTool": "अज्ञात टूल \"{{id}}\"", - "noParameters": "कोई पैरामीटर नहीं।" + "newModel": "नया", + "runModel": "चलाएँ", + "importModel": "आयात", + "exportModel": "निर्यात", + "savedModels": "सहेजे गए मॉडल", + "loadModelPlaceholder": "सहेजा गया मॉडल लोड करें...", + "searchTools": "उपकरण खोजें", + "loadingTools": "उपकरण लोड हो रहे हैं...", + "noToolsMatch": "आपकी खोज से कोई उपकरण मेल नहीं खाता।", + "addInputNode": "+ इनपुट", + "addOutputNode": "+ आउटपुट", + "canvasEmpty": "बनाना शुरू करने के लिए पैलेट से कोई उपकरण खींचें।", + "inputNode": "इनपुट", + "outputNode": "आउटपुट", + "inputPort": "इनपुट: {{port}}", + "outputPort": "आउटपुट: {{port}}", + "removeConnection": "कनेक्शन हटाएँ", + "removeNode": "नोड हटाएँ", + "resizePanel": "पैनल का आकार बदलें", + "selectNodeHint": "सेटिंग्स संपादित करने के लिए कोई नोड चुनें।", + "sourceLayer": "स्रोत परत", + "chooseLayer": "एक परत चुनें...", + "resultName": "परिणाम नाम", + "resultNamePlaceholder": "मॉडल आउटपुट", + "noParameters": "कोई पैरामीटर नहीं।", + "outputPlaceholder": "संदेश यहाँ दिखाई देंगे।", + "connectCycle": "वह कनेक्शन एक लूप बना देगा।", + "connectSameNode": "कोई नोड स्वयं से नहीं जुड़ सकता।", + "fixIssuesFirst": "चलाने से पहले बताई गई समस्याएँ ठीक करें।", + "runFailed": "चलाना विफल रहा", + "runFinished": "चलना पूरा हुआ — {{outputs}} आउटपुट जोड़े गए।", + "savedLog": "मॉडल परियोजना में सहेजा गया।", + "exportedLog": "{{name}} निर्यात किया गया", + "importedLog": "{{nodes}} नोड वाला मॉडल आयात किया गया।", + "importFailed": "आयात विफल रहा", + "importInvalid": "उस फ़ाइल में मॉडल ग्राफ़ नहीं है।", + "rasterOutputUnsupported": "\"{{name}}\" एक रास्टर परिणाम है, जिसे यह बिल्ड मानचित्र में नहीं जोड़ सकता।" }, "parameterField": { "selectLayer": "एक लेयर चुनें...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Python कंसोल", "sqlWorkspace": "SQL कार्यक्षेत्र", "assistant": "सहायक", - "statusBar": "स्टेटस बार" + "statusBar": "स्टेटस बार", + "modelBuilder": "मॉडल बिल्डर" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index cb2ef5cd63..6406f8ad25 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -2498,7 +2498,7 @@ "duckdbLayer": "Layer DuckDB", "whitebox": "Whitebox", "geocode": "Geocode Alamat", - "modelBuilder": "Batch & Model", + "modelBuilder": "Pembuat Model", "processingHistory": "Riwayat", "conversion": "Konversi", "vector": "Vektor", @@ -2662,7 +2662,8 @@ "projectName": "Nama proyek", "storymapEllipsis": "Peta Cerita...", "pointerElevationNoticeTitle": "Elevasi memakai layanan publik", - "pointerElevationNoticeDesc": "Elevasi dihitung dari medan 3D peta bila aktif, tanpa mengirim apa pun. Tanpa medan 3D, API publik Open-Meteo dikueri dan koordinat di bawah penunjuk meninggalkan perangkat Anda." + "pointerElevationNoticeDesc": "Elevasi dihitung dari medan 3D peta bila aktif, tanpa mengirim apa pun. Tanpa medan 3D, API publik Open-Meteo dikueri dan koordinat di bawah penunjuk meninggalkan perangkat Anda.", + "batchTools": "Alat massal" }, "plugin": { "maplibre-gl-annotations": "Anotasi", @@ -3954,38 +3955,61 @@ "count_other": "{{count}} proses tercatat", "toolUnavailable": "Alat \"{{toolId}}\" tidak lagi tersedia" }, - "modelBuilder": { - "moveStepUp": "Pindahkan langkah ke atas", - "moveStepDown": "Pindahkan langkah ke bawah", - "removeStep": "Hapus langkah", - "title": "Batch & Model", - "description": "Jalankan satu alat vektor pada banyak layer, atau rangkai beberapa alat menjadi model yang dapat digunakan ulang dan tersimpan bersama proyek Anda.", - "tabBatch": "Batch", - "tabModels": "Model", - "outputPlaceholder": "Keluaran akan muncul di sini.", + "batchTools": { + "title": "Alat massal", + "description": "Jalankan satu alat vektor pada banyak lapisan sekaligus.", "tool": "Alat", "sharedParameters": "Parameter bersama", "noExtraParameters": "Alat ini tidak memiliki parameter tambahan.", "inputLayers": "Layer masukan", - "selectAll": "Pilih semua", "clearSelection": "Bersihkan", + "selectAll": "Pilih semua", "noCompatibleLayers": "Tidak ada layer GeoJSON yang kompatibel.", - "newModel": "Model baru", - "noSavedModels": "Belum ada model tersimpan.", - "untitledModel": "Model tanpa judul", + "outputPlaceholder": "Keluaran akan muncul di sini." + }, + "modelBuilder": { + "title": "Pembuat Model", + "description": "Seret alat ke kanvas dan hubungkan menjadi sebuah model pemrosesan.", "modelName": "Nama model", - "emptyPipelineHint": "Tambahkan langkah untuk mulai membangun pipeline. Langkah pertama membaca layer masukan; setiap langkah berikutnya menerima keluaran langkah sebelumnya.", - "addStep": "Tambah langkah", - "runModel": "Jalankan model", - "deleteModel": "Hapus", - "canvas": "Kanvas alur kerja spasial", - "importPipeline": "Impor pipeline", - "exportPipeline": "Ekspor pipeline", - "stepKindTransform": "Transformasi", - "canvasEmpty": "Belum ada langkah — tambahkan satu untuk membangun alur kerja.", - "inputPreviousStep": "Masukan: ← keluaran langkah sebelumnya", - "unknownTool": "Alat tidak dikenal \"{{id}}\"", - "noParameters": "Tidak ada parameter." + "modelNamePlaceholder": "Model tanpa judul", + "untitledModel": "Model tanpa judul", + "newModel": "Baru", + "runModel": "Jalankan", + "importModel": "Impor", + "exportModel": "Ekspor", + "savedModels": "Model tersimpan", + "loadModelPlaceholder": "Muat model tersimpan...", + "searchTools": "Cari alat", + "loadingTools": "Memuat alat...", + "noToolsMatch": "Tidak ada alat yang cocok dengan pencarian Anda.", + "addInputNode": "+ Masukan", + "addOutputNode": "+ Keluaran", + "canvasEmpty": "Seret sebuah alat dari palet untuk mulai membangun.", + "inputNode": "Masukan", + "outputNode": "Keluaran", + "inputPort": "Masukan: {{port}}", + "outputPort": "Keluaran: {{port}}", + "removeConnection": "Hapus koneksi", + "removeNode": "Hapus simpul", + "resizePanel": "Ubah ukuran panel", + "selectNodeHint": "Pilih sebuah simpul untuk mengubah pengaturannya.", + "sourceLayer": "Lapisan sumber", + "chooseLayer": "Pilih lapisan...", + "resultName": "Nama hasil", + "resultNamePlaceholder": "Keluaran model", + "noParameters": "Tidak ada parameter.", + "outputPlaceholder": "Pesan muncul di sini.", + "connectCycle": "Koneksi itu akan membuat perulangan.", + "connectSameNode": "Simpul tidak dapat terhubung ke dirinya sendiri.", + "fixIssuesFirst": "Perbaiki masalah yang dilaporkan sebelum menjalankan.", + "runFailed": "Gagal dijalankan", + "runFinished": "Selesai dijalankan — {{outputs}} keluaran ditambahkan.", + "savedLog": "Model disimpan ke proyek.", + "exportedLog": "{{name}} diekspor", + "importedLog": "Mengimpor model dengan {{nodes}} simpul.", + "importFailed": "Gagal mengimpor", + "importInvalid": "Berkas itu tidak berisi graf model.", + "rasterOutputUnsupported": "\"{{name}}\" adalah hasil raster yang tidak dapat ditambahkan ke peta oleh versi ini." }, "parameterField": { "selectLayer": "Pilih layer...", @@ -5402,7 +5426,8 @@ "pythonConsole": "Konsol Python", "sqlWorkspace": "Ruang kerja SQL", "assistant": "Asisten", - "statusBar": "Bilah status" + "statusBar": "Bilah status", + "modelBuilder": "Pembuat Model" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index ae568f7448..443e37621c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "Livello DuckDB", "whitebox": "Whitebox", "geocode": "Geocodifica indirizzi", - "modelBuilder": "Batch e modelli", + "modelBuilder": "Generatore di modelli", "processingHistory": "Cronologia", "conversion": "Conversione", "vector": "Vettoriale", @@ -2710,7 +2710,8 @@ "projectName": "Nome del progetto", "storymapEllipsis": "Mappa narrativa...", "pointerElevationNoticeTitle": "La quota usa un servizio pubblico", - "pointerElevationNoticeDesc": "La quota è ricavata dal terreno 3D della mappa quando è attivo, senza inviare nulla. Senza terreno 3D viene interrogata l'API pubblica Open-Meteo e le coordinate sotto il puntatore lasciano il dispositivo." + "pointerElevationNoticeDesc": "La quota è ricavata dal terreno 3D della mappa quando è attivo, senza inviare nulla. Senza terreno 3D viene interrogata l'API pubblica Open-Meteo e le coordinate sotto il puntatore lasciano il dispositivo.", + "batchTools": "Strumenti in blocco" }, "plugin": { "maplibre-gl-annotations": "Annotazioni", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} esecuzioni registrate", "toolUnavailable": "Lo strumento \"{{toolId}}\" non è più disponibile" }, - "modelBuilder": { - "moveStepUp": "Sposta passaggio in alto", - "moveStepDown": "Sposta passaggio in basso", - "removeStep": "Rimuovi passaggio", - "title": "Batch e modelli", - "description": "Esegui uno strumento vettoriale su molti livelli, oppure concatena gli strumenti in un modello riutilizzabile salvato con il tuo progetto.", - "tabBatch": "Batch", - "tabModels": "Modelli", - "outputPlaceholder": "L'output apparirà qui.", + "batchTools": { + "title": "Strumenti in blocco", + "description": "Esegui uno strumento vettoriale su molti livelli in una volta.", "tool": "Strumento", "sharedParameters": "Parametri condivisi", "noExtraParameters": "Questo strumento non ha parametri aggiuntivi.", "inputLayers": "Livelli di input", - "selectAll": "Seleziona tutto", "clearSelection": "Deseleziona", + "selectAll": "Seleziona tutto", "noCompatibleLayers": "Nessun livello GeoJSON compatibile.", - "newModel": "Nuovo modello", - "noSavedModels": "Nessun modello salvato.", - "untitledModel": "Modello senza titolo", + "outputPlaceholder": "L'output apparirà qui." + }, + "modelBuilder": { + "title": "Generatore di modelli", + "description": "Trascina gli strumenti sull'area di lavoro e collegali in un modello di elaborazione.", "modelName": "Nome del modello", - "emptyPipelineHint": "Aggiungi un passaggio per iniziare a costruire la pipeline. Il primo passaggio legge un livello di input; ogni passaggio successivo riceve l'output del passaggio precedente.", - "addStep": "Aggiungi passaggio", - "runModel": "Esegui il modello", - "deleteModel": "Elimina", - "canvas": "Area del flusso di lavoro spaziale", - "importPipeline": "Importa pipeline", - "exportPipeline": "Esporta pipeline", - "stepKindTransform": "Trasformazione", - "canvasEmpty": "Nessun passaggio — aggiungine uno per creare il flusso di lavoro.", - "inputPreviousStep": "Input: ← output del passaggio precedente", - "unknownTool": "Strumento sconosciuto «{{id}}»", - "noParameters": "Nessun parametro." + "modelNamePlaceholder": "Modello senza titolo", + "untitledModel": "Modello senza titolo", + "newModel": "Nuovo", + "runModel": "Esegui", + "importModel": "Importa", + "exportModel": "Esporta", + "savedModels": "Modelli salvati", + "loadModelPlaceholder": "Carica un modello salvato...", + "searchTools": "Cerca strumenti", + "loadingTools": "Caricamento degli strumenti...", + "noToolsMatch": "Nessuno strumento corrisponde alla ricerca.", + "addInputNode": "+ Ingresso", + "addOutputNode": "+ Uscita", + "canvasEmpty": "Trascina uno strumento dalla tavolozza per iniziare.", + "inputNode": "Ingresso", + "outputNode": "Uscita", + "inputPort": "Ingresso: {{port}}", + "outputPort": "Uscita: {{port}}", + "removeConnection": "Rimuovi collegamento", + "removeNode": "Rimuovi nodo", + "resizePanel": "Ridimensiona il pannello", + "selectNodeHint": "Seleziona un nodo per modificarne le impostazioni.", + "sourceLayer": "Livello di origine", + "chooseLayer": "Scegli un livello...", + "resultName": "Nome del risultato", + "resultNamePlaceholder": "Uscita del modello", + "noParameters": "Nessun parametro.", + "outputPlaceholder": "I messaggi compaiono qui.", + "connectCycle": "Quel collegamento creerebbe un ciclo.", + "connectSameNode": "Un nodo non può collegarsi a se stesso.", + "fixIssuesFirst": "Correggi i problemi segnalati prima di eseguire.", + "runFailed": "Esecuzione non riuscita", + "runFinished": "Esecuzione terminata — {{outputs}} uscita/e aggiunta/e.", + "savedLog": "Modello salvato nel progetto.", + "exportedLog": "{{name}} esportato", + "importedLog": "Importato un modello con {{nodes}} nodo/i.", + "importFailed": "Importazione non riuscita", + "importInvalid": "Quel file non contiene un grafo di modello.", + "rasterOutputUnsupported": "«{{name}}» è un risultato raster che questa build non può aggiungere alla mappa." }, "parameterField": { "selectLayer": "Seleziona un livello...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Console Python", "sqlWorkspace": "Area di lavoro SQL", "assistant": "Assistente", - "statusBar": "Barra di stato" + "statusBar": "Barra di stato", + "modelBuilder": "Generatore di modelli" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 97f23e9c91..5e0b47bc45 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -2498,7 +2498,7 @@ "duckdbLayer": "DuckDB レイヤー", "whitebox": "Whitebox", "geocode": "住所をジオコーディング", - "modelBuilder": "バッチ処理とモデル", + "modelBuilder": "モデルビルダー", "processingHistory": "履歴", "conversion": "変換", "vector": "ベクター", @@ -2662,7 +2662,8 @@ "projectName": "プロジェクト名", "storymapEllipsis": "ストーリーマップ...", "pointerElevationNoticeTitle": "標高は公開サービスを利用します", - "pointerElevationNoticeDesc": "3D地形が有効な場合、標高は地図自体から求められ、何も送信されません。3D地形がない場合は公開のOpen-Meteo APIに問い合わせ、ポインター位置の座標が端末外に送信されます。" + "pointerElevationNoticeDesc": "3D地形が有効な場合、標高は地図自体から求められ、何も送信されません。3D地形がない場合は公開のOpen-Meteo APIに問い合わせ、ポインター位置の座標が端末外に送信されます。", + "batchTools": "バッチツール" }, "plugin": { "maplibre-gl-annotations": "注釈", @@ -3954,38 +3955,61 @@ "count_other": "{{count}} 件の実行を記録", "toolUnavailable": "ツール「{{toolId}}」は利用できなくなりました" }, - "modelBuilder": { - "moveStepUp": "ステップを上に移動", - "moveStepDown": "ステップを下に移動", - "removeStep": "ステップを削除", - "title": "バッチとモデル", - "description": "1 つのベクターツールを多数のレイヤーに対して実行するか、ツールを連結してプロジェクトとともに保存できる再利用可能なモデルを作成します。", - "tabBatch": "バッチ", - "tabModels": "モデル", - "outputPlaceholder": "ここに出力が表示されます。", + "batchTools": { + "title": "バッチツール", + "description": "1 つのベクターツールを多数のレイヤーに一括で実行します。", "tool": "ツール", "sharedParameters": "共通パラメータ", "noExtraParameters": "このツールに追加のパラメータはありません。", "inputLayers": "入力レイヤー", - "selectAll": "すべて選択", "clearSelection": "選択解除", + "selectAll": "すべて選択", "noCompatibleLayers": "対応する GeoJSON レイヤーがありません。", - "newModel": "新しいモデル", - "noSavedModels": "保存されたモデルはまだありません。", - "untitledModel": "無題のモデル", + "outputPlaceholder": "ここに出力が表示されます。" + }, + "modelBuilder": { + "title": "モデルビルダー", + "description": "ツールをキャンバスにドラッグし、つなげて処理モデルを作成します。", "modelName": "モデル名", - "emptyPipelineHint": "ステップを追加してパイプラインの作成を始めましょう。最初のステップは入力レイヤーを読み込み、以降の各ステップは前のステップの出力を受け取ります。", - "addStep": "ステップを追加", - "runModel": "モデルを実行", - "deleteModel": "削除", - "canvas": "空間ワークフローキャンバス", - "importPipeline": "パイプラインをインポート", - "exportPipeline": "パイプラインをエクスポート", - "stepKindTransform": "変換", - "canvasEmpty": "ステップがありません — 追加してワークフローを作成します。", - "inputPreviousStep": "入力: ← 前のステップの出力", - "unknownTool": "不明なツール「{{id}}」", - "noParameters": "パラメータはありません。" + "modelNamePlaceholder": "名称未設定のモデル", + "untitledModel": "名称未設定のモデル", + "newModel": "新規", + "runModel": "実行", + "importModel": "インポート", + "exportModel": "エクスポート", + "savedModels": "保存済みのモデル", + "loadModelPlaceholder": "保存済みモデルを読み込む…", + "searchTools": "ツールを検索", + "loadingTools": "ツールを読み込んでいます…", + "noToolsMatch": "検索に一致するツールはありません。", + "addInputNode": "+ 入力", + "addOutputNode": "+ 出力", + "canvasEmpty": "パレットからツールをドラッグして作成を始めます。", + "inputNode": "入力", + "outputNode": "出力", + "inputPort": "入力: {{port}}", + "outputPort": "出力: {{port}}", + "removeConnection": "接続を削除", + "removeNode": "ノードを削除", + "resizePanel": "パネルのサイズを変更", + "selectNodeHint": "ノードを選択すると設定を編集できます。", + "sourceLayer": "ソースレイヤー", + "chooseLayer": "レイヤーを選択…", + "resultName": "結果名", + "resultNamePlaceholder": "モデル出力", + "noParameters": "パラメータはありません。", + "outputPlaceholder": "メッセージはここに表示されます。", + "connectCycle": "その接続はループを作成します。", + "connectSameNode": "ノードを自分自身に接続することはできません。", + "fixIssuesFirst": "実行する前に報告された問題を修正してください。", + "runFailed": "実行に失敗しました", + "runFinished": "実行が完了しました — {{outputs}} 件の出力を追加しました。", + "savedLog": "モデルをプロジェクトに保存しました。", + "exportedLog": "{{name}} をエクスポートしました", + "importedLog": "{{nodes}} 個のノードを持つモデルをインポートしました。", + "importFailed": "インポートに失敗しました", + "importInvalid": "このファイルにはモデルグラフが含まれていません。", + "rasterOutputUnsupported": "「{{name}}」はラスター結果のため、このビルドでは地図に追加できません。" }, "parameterField": { "selectLayer": "レイヤーを選択...", @@ -5402,7 +5426,8 @@ "pythonConsole": "Python コンソール", "sqlWorkspace": "SQL ワークスペース", "assistant": "アシスタント", - "statusBar": "ステータスバー" + "statusBar": "ステータスバー", + "modelBuilder": "モデルビルダー" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index ab56f8d309..773c04fdc1 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "DuckDB შრე", "whitebox": "Whitebox", "geocode": "მისამართების გეოკოდირება", - "modelBuilder": "პაკეტური & მოდელები", + "modelBuilder": "მოდელის შემქმნელი", "processingHistory": "ისტორია", "conversion": "კონვერტაცია", "vector": "ვექტორი", @@ -2710,7 +2710,8 @@ "projectName": "პროექტის სახელი", "storymapEllipsis": "Story Map...", "pointerElevationNoticeTitle": "სიმაღლე იყენებს საჯარო სერვისს", - "pointerElevationNoticeDesc": "3D რელიეფის ჩართვისას სიმაღლე გამოითვლება თავად რუკიდან და არაფერი იგზავნება. მის გარეშე გამოიყენება საჯარო Open-Meteo API და კურსორის კოორდინატები ტოვებს თქვენს მოწყობილობას." + "pointerElevationNoticeDesc": "3D რელიეფის ჩართვისას სიმაღლე გამოითვლება თავად რუკიდან და არაფერი იგზავნება. მის გარეშე გამოიყენება საჯარო Open-Meteo API და კურსორის კოორდინატები ტოვებს თქვენს მოწყობილობას.", + "batchTools": "სერიული ხელსაწყოები" }, "plugin": { "maplibre-gl-annotations": "ანოტაციები", @@ -4021,38 +4022,61 @@ "count_other": "ჩაწერილია {{count}} გაშვება", "toolUnavailable": "ხელსაწყო \"{{toolId}}\" აღარ არის ხელმისაწვდომი" }, - "modelBuilder": { - "moveStepUp": "ნაბიჯის აწევა", - "moveStepDown": "ნაბიჯის დაწევა", - "removeStep": "ნაბიჯის წაშლა", - "title": "პაკეტური დამუშავება და მოდელები", - "description": "გაუშვით ვექტორული ხელსაწყო მრავალ შრეზე, ან დააკავშირეთ ხელსაწყოები თქვენს პროექტთან ერთად შენახულ მრავალჯერად მოდელში.", - "tabBatch": "პაკეტი", - "tabModels": "მოდელები", - "outputPlaceholder": "შედეგი აქ გამოჩნდება.", + "batchTools": { + "title": "სერიული ხელსაწყოები", + "description": "ერთი ვექტორული ხელსაწყოს გაშვება მრავალ ფენაზე ერთდროულად.", "tool": "ხელსაწყო", "sharedParameters": "საერთო პარამეტრები", "noExtraParameters": "ამ ხელსაწყოს დამატებითი პარამეტრები არ აქვს.", "inputLayers": "შემავალი შრეები", - "selectAll": "ყველას მონიშვნა", "clearSelection": "გასუფთავება", + "selectAll": "ყველას მონიშვნა", "noCompatibleLayers": "თავსებადი GeoJSON შრეები არ არის.", - "newModel": "ახალი მოდელი", - "noSavedModels": "შენახული მოდელები ჯერ არ არის.", - "untitledModel": "უსახელო მოდელი", + "outputPlaceholder": "შედეგი აქ გამოჩნდება." + }, + "modelBuilder": { + "title": "მოდელის შემქმნელი", + "description": "გადმოიტანეთ ხელსაწყოები ტილოზე და დააკავშირეთ ისინი დამუშავების მოდელად.", "modelName": "მოდელის სახელი", - "emptyPipelineHint": "დაამატეთ ნაბიჯი კონვეიერის ასაგებად. პირველი ნაბიჯი კითხულობს შემავალ შრეს; ყოველი შემდეგი ნაბიჯი იღებს წინა ნაბიჯის შედეგს.", - "addStep": "ნაბიჯის დამატება", - "runModel": "მოდელის გაშვება", - "deleteModel": "წაშლა", - "canvas": "სივრცითი სამუშაო ნაკადის ტილო", - "importPipeline": "კონვეიერის იმპორტი", - "exportPipeline": "კონვეიერის ექსპორტი", - "stepKindTransform": "გარდაქმნა", - "canvasEmpty": "ჯერ არ არის ნაბიჯები — დაამატეთ ერთი სამუშაო ნაკადის ასაგებად.", - "inputPreviousStep": "შესატანი: ← წინა ნაბიჯის შედეგი", - "unknownTool": "უცნობი ხელსაწყო „{{id}}“", - "noParameters": "პარამეტრები არ არის." + "modelNamePlaceholder": "უსათაურო მოდელი", + "untitledModel": "უსათაურო მოდელი", + "newModel": "ახალი", + "runModel": "გაშვება", + "importModel": "იმპორტი", + "exportModel": "ექსპორტი", + "savedModels": "შენახული მოდელები", + "loadModelPlaceholder": "შენახული მოდელის ჩატვირთვა...", + "searchTools": "ხელსაწყოების ძებნა", + "loadingTools": "ხელსაწყოები იტვირთება...", + "noToolsMatch": "თქვენს ძებნას ხელსაწყო არ ემთხვევა.", + "addInputNode": "+ შემავალი", + "addOutputNode": "+ გამომავალი", + "canvasEmpty": "დასაწყებად გადმოიტანეთ ხელსაწყო პალიტრიდან.", + "inputNode": "შემავალი", + "outputNode": "გამომავალი", + "inputPort": "შემავალი: {{port}}", + "outputPort": "გამომავალი: {{port}}", + "removeConnection": "კავშირის წაშლა", + "removeNode": "კვანძის წაშლა", + "resizePanel": "პანელის ზომის შეცვლა", + "selectNodeHint": "აირჩიეთ კვანძი მისი პარამეტრების შესაცვლელად.", + "sourceLayer": "წყაროს ფენა", + "chooseLayer": "აირჩიეთ ფენა...", + "resultName": "შედეგის სახელი", + "resultNamePlaceholder": "მოდელის გამომავალი", + "noParameters": "პარამეტრები არ არის.", + "outputPlaceholder": "შეტყობინებები აქ გამოჩნდება.", + "connectCycle": "ეს კავშირი მარყუჟს შექმნის.", + "connectSameNode": "კვანძი საკუთარ თავს ვერ დაუკავშირდება.", + "fixIssuesFirst": "გაშვებამდე გამოასწორეთ მითითებული პრობლემები.", + "runFailed": "გაშვება ვერ მოხერხდა", + "runFinished": "გაშვება დასრულდა — დაემატა {{outputs}} გამომავალი.", + "savedLog": "მოდელი შენახულია პროექტში.", + "exportedLog": "{{name}} ექსპორტირებულია", + "importedLog": "იმპორტირებულია მოდელი {{nodes}} კვანძით.", + "importFailed": "იმპორტი ვერ მოხერხდა", + "importInvalid": "ეს ფაილი მოდელის გრაფს არ შეიცავს.", + "rasterOutputUnsupported": "„{{name}}“ არის რასტრული შედეგი, რომელსაც ეს ბილდი რუკაზე ვერ დაამატებს." }, "parameterField": { "selectLayer": "აირჩიეთ ფენა...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Python-ის კონსოლი", "sqlWorkspace": "SQL სამუშაო სივრცე", "assistant": "ასისტენტი", - "statusBar": "სტატუსის ზოლი" + "statusBar": "სტატუსის ზოლი", + "modelBuilder": "მოდელის შემქმნელი" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index c7f0dda00d..920253a05a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -2498,7 +2498,7 @@ "duckdbLayer": "DuckDB 레이어", "whitebox": "Whitebox", "geocode": "주소 지오코딩", - "modelBuilder": "배치 및 모델", + "modelBuilder": "모델 빌더", "processingHistory": "기록", "conversion": "변환", "vector": "벡터", @@ -2662,7 +2662,8 @@ "projectName": "프로젝트 이름", "storymapEllipsis": "스토리 맵...", "pointerElevationNoticeTitle": "고도는 공개 서비스를 사용합니다", - "pointerElevationNoticeDesc": "3D 지형이 켜져 있으면 고도는 지도 자체에서 계산되며 아무것도 전송되지 않습니다. 3D 지형이 없으면 공개 Open-Meteo API를 조회하며 포인터 아래 좌표가 기기를 벗어납니다." + "pointerElevationNoticeDesc": "3D 지형이 켜져 있으면 고도는 지도 자체에서 계산되며 아무것도 전송되지 않습니다. 3D 지형이 없으면 공개 Open-Meteo API를 조회하며 포인터 아래 좌표가 기기를 벗어납니다.", + "batchTools": "일괄 도구" }, "plugin": { "maplibre-gl-annotations": "주석", @@ -3954,38 +3955,61 @@ "count_other": "{{count}}개 실행 기록됨", "toolUnavailable": "도구 \"{{toolId}}\"을(를) 더 이상 사용할 수 없습니다" }, - "modelBuilder": { - "moveStepUp": "단계 위로 이동", - "moveStepDown": "단계 아래로 이동", - "removeStep": "단계 제거", - "title": "일괄 처리 및 모델", - "description": "여러 레이어에 벡터 도구를 실행하거나, 도구를 연결해 프로젝트와 함께 저장되는 재사용 가능한 모델을 만듭니다.", - "tabBatch": "일괄 처리", - "tabModels": "모델", - "outputPlaceholder": "여기에 출력이 표시됩니다.", + "batchTools": { + "title": "일괄 도구", + "description": "하나의 벡터 도구를 여러 레이어에 한 번에 실행합니다.", "tool": "도구", "sharedParameters": "공통 매개변수", "noExtraParameters": "이 도구에는 추가 매개변수가 없습니다.", "inputLayers": "입력 레이어", - "selectAll": "모두 선택", "clearSelection": "선택 해제", + "selectAll": "모두 선택", "noCompatibleLayers": "호환되는 GeoJSON 레이어가 없습니다.", - "newModel": "새 모델", - "noSavedModels": "저장된 모델이 아직 없습니다.", - "untitledModel": "제목 없는 모델", + "outputPlaceholder": "여기에 출력이 표시됩니다." + }, + "modelBuilder": { + "title": "모델 빌더", + "description": "도구를 캔버스로 끌어다 놓고 서로 연결하여 처리 모델을 만듭니다.", "modelName": "모델 이름", - "emptyPipelineHint": "단계를 추가해 파이프라인을 구성하세요. 첫 단계는 입력 레이어를 읽고, 이후 각 단계는 이전 단계의 출력을 받습니다.", - "addStep": "단계 추가", - "runModel": "모델 실행", - "deleteModel": "삭제", - "canvas": "공간 워크플로 캔버스", - "importPipeline": "파이프라인 가져오기", - "exportPipeline": "파이프라인 내보내기", - "stepKindTransform": "변환", - "canvasEmpty": "아직 단계가 없습니다 — 워크플로를 만들려면 추가하세요.", - "inputPreviousStep": "입력: ← 이전 단계의 출력", - "unknownTool": "알 수 없는 도구 \"{{id}}\"", - "noParameters": "매개변수가 없습니다." + "modelNamePlaceholder": "제목 없는 모델", + "untitledModel": "제목 없는 모델", + "newModel": "새로 만들기", + "runModel": "실행", + "importModel": "가져오기", + "exportModel": "내보내기", + "savedModels": "저장된 모델", + "loadModelPlaceholder": "저장된 모델 불러오기...", + "searchTools": "도구 검색", + "loadingTools": "도구를 불러오는 중...", + "noToolsMatch": "검색과 일치하는 도구가 없습니다.", + "addInputNode": "+ 입력", + "addOutputNode": "+ 출력", + "canvasEmpty": "팔레트에서 도구를 끌어다 놓아 시작하세요.", + "inputNode": "입력", + "outputNode": "출력", + "inputPort": "입력: {{port}}", + "outputPort": "출력: {{port}}", + "removeConnection": "연결 제거", + "removeNode": "노드 제거", + "resizePanel": "패널 크기 조정", + "selectNodeHint": "노드를 선택하면 설정을 편집할 수 있습니다.", + "sourceLayer": "원본 레이어", + "chooseLayer": "레이어 선택...", + "resultName": "결과 이름", + "resultNamePlaceholder": "모델 출력", + "noParameters": "매개변수가 없습니다.", + "outputPlaceholder": "메시지가 여기에 표시됩니다.", + "connectCycle": "그 연결은 순환을 만듭니다.", + "connectSameNode": "노드는 자기 자신에 연결할 수 없습니다.", + "fixIssuesFirst": "실행하기 전에 보고된 문제를 해결하세요.", + "runFailed": "실행 실패", + "runFinished": "실행 완료 — 출력 {{outputs}}개를 추가했습니다.", + "savedLog": "모델을 프로젝트에 저장했습니다.", + "exportedLog": "{{name}}을(를) 내보냈습니다", + "importedLog": "노드 {{nodes}}개인 모델을 가져왔습니다.", + "importFailed": "가져오기 실패", + "importInvalid": "해당 파일에는 모델 그래프가 없습니다.", + "rasterOutputUnsupported": "\"{{name}}\"은(는) 래스터 결과이며 이 빌드에서는 지도에 추가할 수 없습니다." }, "parameterField": { "selectLayer": "레이어 선택...", @@ -5402,7 +5426,8 @@ "pythonConsole": "Python 콘솔", "sqlWorkspace": "SQL 작업 공간", "assistant": "어시스턴트", - "statusBar": "상태 표시줄" + "statusBar": "상태 표시줄", + "modelBuilder": "모델 빌더" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index dc3b5b7b51..2da9eb381c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "DuckDB-laag", "whitebox": "Whitebox", "geocode": "Adressen geocoderen", - "modelBuilder": "Batches & modellen", + "modelBuilder": "Modelbouwer", "processingHistory": "Geschiedenis", "conversion": "Conversie", "vector": "Vector", @@ -2710,7 +2710,8 @@ "projectName": "Projectnaam", "storymapEllipsis": "Story Map...", "pointerElevationNoticeTitle": "Hoogte gebruikt een openbare dienst", - "pointerElevationNoticeDesc": "De hoogte wordt uit het 3D-terrein van de kaart bepaald als dat aanstaat; er wordt dan niets verzonden. Zonder 3D-terrein wordt de openbare Open-Meteo-API geraadpleegd en verlaten de coördinaten onder de aanwijzer uw apparaat." + "pointerElevationNoticeDesc": "De hoogte wordt uit het 3D-terrein van de kaart bepaald als dat aanstaat; er wordt dan niets verzonden. Zonder 3D-terrein wordt de openbare Open-Meteo-API geraadpleegd en verlaten de coördinaten onder de aanwijzer uw apparaat.", + "batchTools": "Batchgereedschappen" }, "plugin": { "maplibre-gl-annotations": "Annotaties", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} uitvoeringen vastgelegd", "toolUnavailable": "Tool \"{{toolId}}\" is niet meer beschikbaar" }, - "modelBuilder": { - "moveStepUp": "Stap omhoog verplaatsen", - "moveStepDown": "Stap omlaag verplaatsen", - "removeStep": "Stap verwijderen", - "title": "Batch & modellen", - "description": "Voer een vectorgereedschap uit over veel lagen, of rijg gereedschappen aaneen tot een herbruikbaar model dat met uw project wordt opgeslagen.", - "tabBatch": "Batch", - "tabModels": "Modellen", - "outputPlaceholder": "De uitvoer verschijnt hier.", + "batchTools": { + "title": "Batchgereedschappen", + "description": "Eén vectorgereedschap op meerdere lagen tegelijk uitvoeren.", "tool": "Gereedschap", "sharedParameters": "Gedeelde parameters", "noExtraParameters": "Dit gereedschap heeft geen extra parameters.", "inputLayers": "Invoerlagen", - "selectAll": "Alles selecteren", "clearSelection": "Wissen", + "selectAll": "Alles selecteren", "noCompatibleLayers": "Geen compatibele GeoJSON-lagen.", - "newModel": "Nieuw model", - "noSavedModels": "Nog geen opgeslagen modellen.", - "untitledModel": "Naamloos model", + "outputPlaceholder": "De uitvoer verschijnt hier." + }, + "modelBuilder": { + "title": "Modelbouwer", + "description": "Sleep gereedschappen naar het canvas en verbind ze tot een verwerkingsmodel.", "modelName": "Modelnaam", - "emptyPipelineHint": "Voeg een stap toe om de pijplijn op te bouwen. De eerste stap leest een invoerlaag; elke volgende stap krijgt de uitvoer van de vorige stap.", - "addStep": "Stap toevoegen", - "runModel": "Model uitvoeren", - "deleteModel": "Verwijderen", - "canvas": "Canvas voor ruimtelijke workflow", - "importPipeline": "Pipeline importeren", - "exportPipeline": "Pipeline exporteren", - "stepKindTransform": "Transformatie", - "canvasEmpty": "Nog geen stappen — voeg er een toe om de workflow op te bouwen.", - "inputPreviousStep": "Invoer: ← uitvoer van de vorige stap", - "unknownTool": "Onbekend gereedschap ‘{{id}}’", - "noParameters": "Geen parameters." + "modelNamePlaceholder": "Naamloos model", + "untitledModel": "Naamloos model", + "newModel": "Nieuw", + "runModel": "Uitvoeren", + "importModel": "Importeren", + "exportModel": "Exporteren", + "savedModels": "Opgeslagen modellen", + "loadModelPlaceholder": "Een opgeslagen model laden...", + "searchTools": "Gereedschappen zoeken", + "loadingTools": "Gereedschappen laden...", + "noToolsMatch": "Geen gereedschappen komen overeen met uw zoekopdracht.", + "addInputNode": "+ Invoer", + "addOutputNode": "+ Uitvoer", + "canvasEmpty": "Sleep een gereedschap uit het palet om te beginnen.", + "inputNode": "Invoer", + "outputNode": "Uitvoer", + "inputPort": "Invoer: {{port}}", + "outputPort": "Uitvoer: {{port}}", + "removeConnection": "Verbinding verwijderen", + "removeNode": "Knooppunt verwijderen", + "resizePanel": "Paneelgrootte wijzigen", + "selectNodeHint": "Selecteer een knooppunt om de instellingen te bewerken.", + "sourceLayer": "Bronlaag", + "chooseLayer": "Kies een laag...", + "resultName": "Resultaatnaam", + "resultNamePlaceholder": "Modeluitvoer", + "noParameters": "Geen parameters.", + "outputPlaceholder": "Berichten verschijnen hier.", + "connectCycle": "Die verbinding zou een lus maken.", + "connectSameNode": "Een knooppunt kan niet met zichzelf verbinden.", + "fixIssuesFirst": "Los de gemelde problemen op voordat u uitvoert.", + "runFailed": "Uitvoeren mislukt", + "runFinished": "Uitvoeren voltooid — {{outputs}} uitvoer(en) toegevoegd.", + "savedLog": "Model opgeslagen in het project.", + "exportedLog": "{{name}} geëxporteerd", + "importedLog": "Een model met {{nodes}} knooppunt(en) geïmporteerd.", + "importFailed": "Importeren mislukt", + "importInvalid": "Dat bestand bevat geen modelgraaf.", + "rasterOutputUnsupported": "“{{name}}” is een rasterresultaat dat deze build niet aan de kaart kan toevoegen." }, "parameterField": { "selectLayer": "Selecteer een laag...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Python-console", "sqlWorkspace": "SQL-werkruimte", "assistant": "Assistent", - "statusBar": "Statusbalk" + "statusBar": "Statusbalk", + "modelBuilder": "Modelbouwer" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index eacec1ba7c..d946e7ffd0 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "Camada DuckDB", "whitebox": "Whitebox", "geocode": "Geocodificar endereços", - "modelBuilder": "Lotes e modelos", + "modelBuilder": "Construtor de modelos", "processingHistory": "Histórico", "conversion": "Conversão", "vector": "Vetor", @@ -2710,7 +2710,8 @@ "projectName": "Nome do projeto", "storymapEllipsis": "Story Map...", "pointerElevationNoticeTitle": "A elevação usa um serviço público", - "pointerElevationNoticeDesc": "A elevação é obtida do relevo 3D do mapa quando este está ativo, sem enviar nada. Sem relevo 3D é consultada a API pública Open-Meteo e as coordenadas sob o ponteiro saem do seu dispositivo." + "pointerElevationNoticeDesc": "A elevação é obtida do relevo 3D do mapa quando este está ativo, sem enviar nada. Sem relevo 3D é consultada a API pública Open-Meteo e as coordenadas sob o ponteiro saem do seu dispositivo.", + "batchTools": "Ferramentas em lote" }, "plugin": { "maplibre-gl-annotations": "Anotações", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} execuções registradas", "toolUnavailable": "A ferramenta \"{{toolId}}\" não está mais disponível" }, - "modelBuilder": { - "moveStepUp": "Mover etapa para cima", - "moveStepDown": "Mover etapa para baixo", - "removeStep": "Remover etapa", - "title": "Lotes e modelos", - "description": "Execute uma ferramenta vetorial em várias camadas, ou encadeie ferramentas em um modelo reutilizável salvo com o seu projeto.", - "tabBatch": "Lote", - "tabModels": "Modelos", - "outputPlaceholder": "A saída aparecerá aqui.", + "batchTools": { + "title": "Ferramentas em lote", + "description": "Executar uma ferramenta vetorial em várias camadas de uma vez.", "tool": "Ferramenta", "sharedParameters": "Parâmetros compartilhados", "noExtraParameters": "Esta ferramenta não tem parâmetros adicionais.", "inputLayers": "Camadas de entrada", - "selectAll": "Selecionar tudo", "clearSelection": "Limpar", + "selectAll": "Selecionar tudo", "noCompatibleLayers": "Nenhuma camada GeoJSON compatível.", - "newModel": "Novo modelo", - "noSavedModels": "Ainda não há modelos salvos.", - "untitledModel": "Modelo sem título", + "outputPlaceholder": "A saída aparecerá aqui." + }, + "modelBuilder": { + "title": "Construtor de modelos", + "description": "Arraste ferramentas para a tela e conecte-as num modelo de processamento.", "modelName": "Nome do modelo", - "emptyPipelineHint": "Adicione uma etapa para começar a montar o fluxo. A primeira etapa lê uma camada de entrada; cada etapa seguinte recebe a saída da etapa anterior.", - "addStep": "Adicionar etapa", - "runModel": "Executar o modelo", - "deleteModel": "Excluir", - "canvas": "Tela de fluxo de trabalho espacial", - "importPipeline": "Importar pipeline", - "exportPipeline": "Exportar pipeline", - "stepKindTransform": "Transformação", - "canvasEmpty": "Ainda não há etapas — adicione uma para criar o fluxo de trabalho.", - "inputPreviousStep": "Entrada: ← saída da etapa anterior", - "unknownTool": "Ferramenta desconhecida “{{id}}”", - "noParameters": "Sem parâmetros." + "modelNamePlaceholder": "Modelo sem título", + "untitledModel": "Modelo sem título", + "newModel": "Novo", + "runModel": "Executar", + "importModel": "Importar", + "exportModel": "Exportar", + "savedModels": "Modelos guardados", + "loadModelPlaceholder": "Carregar um modelo guardado...", + "searchTools": "Pesquisar ferramentas", + "loadingTools": "A carregar ferramentas...", + "noToolsMatch": "Nenhuma ferramenta corresponde à sua pesquisa.", + "addInputNode": "+ Entrada", + "addOutputNode": "+ Saída", + "canvasEmpty": "Arraste uma ferramenta da paleta para começar.", + "inputNode": "Entrada", + "outputNode": "Saída", + "inputPort": "Entrada: {{port}}", + "outputPort": "Saída: {{port}}", + "removeConnection": "Remover ligação", + "removeNode": "Remover nó", + "resizePanel": "Redimensionar painel", + "selectNodeHint": "Selecione um nó para editar as suas definições.", + "sourceLayer": "Camada de origem", + "chooseLayer": "Escolher uma camada...", + "resultName": "Nome do resultado", + "resultNamePlaceholder": "Saída do modelo", + "noParameters": "Sem parâmetros.", + "outputPlaceholder": "As mensagens aparecem aqui.", + "connectCycle": "Essa ligação criaria um ciclo.", + "connectSameNode": "Um nó não pode ligar-se a si próprio.", + "fixIssuesFirst": "Corrija os problemas indicados antes de executar.", + "runFailed": "A execução falhou", + "runFinished": "Execução concluída — {{outputs}} saída(s) adicionada(s).", + "savedLog": "Modelo guardado no projeto.", + "exportedLog": "{{name}} exportado", + "importedLog": "Importado um modelo com {{nodes}} nó(s).", + "importFailed": "A importação falhou", + "importInvalid": "Esse ficheiro não contém um grafo de modelo.", + "rasterOutputUnsupported": "«{{name}}» é um resultado raster que esta versão não consegue adicionar ao mapa." }, "parameterField": { "selectLayer": "Selecionar uma camada...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Console Python", "sqlWorkspace": "Espaço de trabalho SQL", "assistant": "Assistente", - "statusBar": "Barra de status" + "statusBar": "Barra de status", + "modelBuilder": "Construtor de modelos" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 5371849c05..9745cf1c57 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -2633,7 +2633,7 @@ "duckdbLayer": "Слой DuckDB", "whitebox": "Whitebox", "geocode": "Геокодировать адреса", - "modelBuilder": "Пакеты и модели", + "modelBuilder": "Конструктор моделей", "processingHistory": "История", "conversion": "Конвертация", "vector": "Вектор", @@ -2806,7 +2806,8 @@ "projectName": "Имя проекта", "storymapEllipsis": "История на карте...", "pointerElevationNoticeTitle": "Высота использует публичный сервис", - "pointerElevationNoticeDesc": "При включённом 3D-рельефе высота вычисляется по самой карте и ничего не отправляется. Без 3D-рельефа запрашивается публичный Open-Meteo API, и координаты под указателем покидают ваше устройство." + "pointerElevationNoticeDesc": "При включённом 3D-рельефе высота вычисляется по самой карте и ничего не отправляется. Без 3D-рельефа запрашивается публичный Open-Meteo API, и координаты под указателем покидают ваше устройство.", + "batchTools": "Пакетные инструменты" }, "plugin": { "maplibre-gl-annotations": "Аннотации", @@ -4155,38 +4156,61 @@ "count_other": "Записано {{count}} запуска", "toolUnavailable": "Инструмент «{{toolId}}» больше недоступен" }, - "modelBuilder": { - "moveStepUp": "Переместить шаг вверх", - "moveStepDown": "Переместить шаг вниз", - "removeStep": "Удалить шаг", - "title": "Пакетная обработка и модели", - "description": "Запустите векторный инструмент для множества слоёв или объедините инструменты в переиспользуемую модель, сохраняемую вместе с проектом.", - "tabBatch": "Пакет", - "tabModels": "Модели", - "outputPlaceholder": "Здесь появится вывод.", + "batchTools": { + "title": "Пакетные инструменты", + "description": "Запустить один векторный инструмент сразу для многих слоёв.", "tool": "Инструмент", "sharedParameters": "Общие параметры", "noExtraParameters": "У этого инструмента нет дополнительных параметров.", "inputLayers": "Входные слои", - "selectAll": "Выбрать все", "clearSelection": "Очистить", + "selectAll": "Выбрать все", "noCompatibleLayers": "Нет совместимых слоёв GeoJSON.", - "newModel": "Новая модель", - "noSavedModels": "Сохранённых моделей пока нет.", + "outputPlaceholder": "Здесь появится вывод." + }, + "modelBuilder": { + "title": "Конструктор моделей", + "description": "Перетащите инструменты на холст и соедините их в модель обработки.", + "modelName": "Имя модели", + "modelNamePlaceholder": "Модель без названия", "untitledModel": "Модель без названия", - "modelName": "Название модели", - "emptyPipelineHint": "Добавьте шаг, чтобы начать построение конвейера. Первый шаг читает входной слой; каждый следующий шаг получает вывод предыдущего.", - "addStep": "Добавить шаг", - "runModel": "Запустить модель", - "deleteModel": "Удалить", - "canvas": "Холст пространственного рабочего процесса", - "importPipeline": "Импортировать конвейер", - "exportPipeline": "Экспортировать конвейер", - "stepKindTransform": "Преобразование", - "canvasEmpty": "Шагов пока нет — добавьте шаг, чтобы построить рабочий процесс.", - "inputPreviousStep": "Вход: ← вывод предыдущего шага", - "unknownTool": "Неизвестный инструмент «{{id}}»", - "noParameters": "Нет параметров." + "newModel": "Создать", + "runModel": "Запустить", + "importModel": "Импорт", + "exportModel": "Экспорт", + "savedModels": "Сохранённые модели", + "loadModelPlaceholder": "Загрузить сохранённую модель...", + "searchTools": "Поиск инструментов", + "loadingTools": "Загрузка инструментов...", + "noToolsMatch": "Нет инструментов, соответствующих запросу.", + "addInputNode": "+ Вход", + "addOutputNode": "+ Выход", + "canvasEmpty": "Перетащите инструмент из палитры, чтобы начать.", + "inputNode": "Вход", + "outputNode": "Выход", + "inputPort": "Вход: {{port}}", + "outputPort": "Выход: {{port}}", + "removeConnection": "Удалить связь", + "removeNode": "Удалить узел", + "resizePanel": "Изменить размер панели", + "selectNodeHint": "Выберите узел, чтобы изменить его настройки.", + "sourceLayer": "Исходный слой", + "chooseLayer": "Выберите слой...", + "resultName": "Имя результата", + "resultNamePlaceholder": "Вывод модели", + "noParameters": "Нет параметров.", + "outputPlaceholder": "Здесь появятся сообщения.", + "connectCycle": "Эта связь создаст цикл.", + "connectSameNode": "Узел не может быть связан сам с собой.", + "fixIssuesFirst": "Устраните указанные проблемы перед запуском.", + "runFailed": "Не удалось выполнить", + "runFinished": "Выполнение завершено — добавлено выходов: {{outputs}}.", + "savedLog": "Модель сохранена в проекте.", + "exportedLog": "{{name}} экспортирован", + "importedLog": "Импортирована модель с узлами: {{nodes}}.", + "importFailed": "Не удалось импортировать", + "importInvalid": "Этот файл не содержит графа модели.", + "rasterOutputUnsupported": "«{{name}}» — растровый результат, который эта сборка не может добавить на карту." }, "parameterField": { "selectLayer": "Выберите слой...", @@ -5642,7 +5666,8 @@ "pythonConsole": "Консоль Python", "sqlWorkspace": "Рабочее пространство SQL", "assistant": "Ассистент", - "statusBar": "Строка состояния" + "statusBar": "Строка состояния", + "modelBuilder": "Конструктор моделей" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 26e506ca97..17e63db42f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -2498,7 +2498,7 @@ "duckdbLayer": "เลเยอร์ DuckDB", "whitebox": "Whitebox", "geocode": "แปลงที่อยู่เป็นพิกัด", - "modelBuilder": "งานแบบชุดและโมเดล", + "modelBuilder": "ตัวสร้างแบบจำลอง", "processingHistory": "ประวัติ", "conversion": "การแปลงรูปแบบ", "vector": "เวกเตอร์", @@ -2662,7 +2662,8 @@ "projectName": "ชื่อโปรเจกต์", "storymapEllipsis": "แผนที่เล่าเรื่อง...", "pointerElevationNoticeTitle": "ระดับความสูงใช้บริการสาธารณะ", - "pointerElevationNoticeDesc": "เมื่อเปิดภูมิประเทศ 3 มิติ ระดับความสูงจะคำนวณจากแผนที่เองโดยไม่ส่งข้อมูลใด ๆ หากไม่มีภูมิประเทศ 3 มิติ จะสอบถาม Open-Meteo API สาธารณะ และพิกัดใต้ตัวชี้จะออกจากอุปกรณ์ของคุณ" + "pointerElevationNoticeDesc": "เมื่อเปิดภูมิประเทศ 3 มิติ ระดับความสูงจะคำนวณจากแผนที่เองโดยไม่ส่งข้อมูลใด ๆ หากไม่มีภูมิประเทศ 3 มิติ จะสอบถาม Open-Meteo API สาธารณะ และพิกัดใต้ตัวชี้จะออกจากอุปกรณ์ของคุณ", + "batchTools": "เครื่องมือแบบกลุ่ม" }, "plugin": { "maplibre-gl-annotations": "คำอธิบายประกอบ", @@ -3954,38 +3955,61 @@ "count_other": "บันทึกการประมวลผลไว้ {{count}} ครั้ง", "toolUnavailable": "ไม่มีเครื่องมือ \"{{toolId}}\" ให้ใช้งานแล้ว" }, - "modelBuilder": { - "moveStepUp": "เลื่อนขั้นตอนขึ้น", - "moveStepDown": "เลื่อนขั้นตอนลง", - "removeStep": "นำขั้นตอนออก", - "title": "งานแบบชุดและโมเดล", - "description": "เรียกใช้เครื่องมือเวกเตอร์กับหลายเลเยอร์พร้อมกัน หรือเชื่อมโยงเครื่องมือหลายตัวเป็นโมเดลที่นำกลับมาใช้ใหม่ได้และบันทึกไว้กับโปรเจกต์ของคุณ", - "tabBatch": "งานแบบชุด", - "tabModels": "โมเดล", - "outputPlaceholder": "ผลลัพธ์จะแสดงที่นี่", + "batchTools": { + "title": "เครื่องมือแบบกลุ่ม", + "description": "เรียกใช้เครื่องมือเวกเตอร์เดียวกับหลายชั้นข้อมูลพร้อมกัน", "tool": "เครื่องมือ", "sharedParameters": "พารามิเตอร์ที่ใช้ร่วมกัน", "noExtraParameters": "เครื่องมือนี้ไม่มีพารามิเตอร์เพิ่มเติม", "inputLayers": "เลเยอร์นำเข้า", - "selectAll": "เลือกทั้งหมด", "clearSelection": "ล้าง", + "selectAll": "เลือกทั้งหมด", "noCompatibleLayers": "ไม่มีเลเยอร์ GeoJSON ที่ใช้งานร่วมกันได้", - "newModel": "โมเดลใหม่", - "noSavedModels": "ยังไม่มีโมเดลที่บันทึกไว้", - "untitledModel": "โมเดลไม่มีชื่อ", - "modelName": "ชื่อโมเดล", - "emptyPipelineHint": "เพิ่มขั้นตอนเพื่อเริ่มสร้างไปป์ไลน์ ขั้นตอนแรกจะอ่านเลเยอร์นำเข้า ส่วนขั้นตอนถัดไปจะรับผลลัพธ์จากขั้นตอนก่อนหน้า", - "addStep": "เพิ่มขั้นตอน", - "runModel": "เรียกใช้โมเดล", - "deleteModel": "ลบ", - "canvas": "พื้นที่ขั้นตอนการทำงานเชิงพื้นที่", - "importPipeline": "นำเข้าไปป์ไลน์", - "exportPipeline": "ส่งออกไปป์ไลน์", - "stepKindTransform": "การแปลง", - "canvasEmpty": "ยังไม่มีขั้นตอน — เพิ่มขั้นตอนเพื่อสร้างเวิร์กโฟลว์", - "inputPreviousStep": "ข้อมูลนำเข้า: ← ผลลัพธ์จากขั้นตอนก่อนหน้า", - "unknownTool": "ไม่รู้จักเครื่องมือ \"{{id}}\"", - "noParameters": "ไม่มีพารามิเตอร์" + "outputPlaceholder": "ผลลัพธ์จะแสดงที่นี่" + }, + "modelBuilder": { + "title": "ตัวสร้างแบบจำลอง", + "description": "ลากเครื่องมือมาวางบนพื้นที่ทำงานแล้วเชื่อมต่อกันเป็นแบบจำลองการประมวลผล", + "modelName": "ชื่อแบบจำลอง", + "modelNamePlaceholder": "แบบจำลองไม่มีชื่อ", + "untitledModel": "แบบจำลองไม่มีชื่อ", + "newModel": "ใหม่", + "runModel": "เรียกใช้", + "importModel": "นำเข้า", + "exportModel": "ส่งออก", + "savedModels": "แบบจำลองที่บันทึกไว้", + "loadModelPlaceholder": "โหลดแบบจำลองที่บันทึกไว้...", + "searchTools": "ค้นหาเครื่องมือ", + "loadingTools": "กำลังโหลดเครื่องมือ...", + "noToolsMatch": "ไม่มีเครื่องมือที่ตรงกับการค้นหาของคุณ", + "addInputNode": "+ อินพุต", + "addOutputNode": "+ เอาต์พุต", + "canvasEmpty": "ลากเครื่องมือจากแผงเครื่องมือเพื่อเริ่มสร้าง", + "inputNode": "อินพุต", + "outputNode": "เอาต์พุต", + "inputPort": "อินพุต: {{port}}", + "outputPort": "เอาต์พุต: {{port}}", + "removeConnection": "ลบการเชื่อมต่อ", + "removeNode": "ลบโหนด", + "resizePanel": "ปรับขนาดแผง", + "selectNodeHint": "เลือกโหนดเพื่อแก้ไขการตั้งค่า", + "sourceLayer": "ชั้นข้อมูลต้นทาง", + "chooseLayer": "เลือกชั้นข้อมูล...", + "resultName": "ชื่อผลลัพธ์", + "resultNamePlaceholder": "เอาต์พุตของแบบจำลอง", + "noParameters": "ไม่มีพารามิเตอร์", + "outputPlaceholder": "ข้อความจะปรากฏที่นี่", + "connectCycle": "การเชื่อมต่อนั้นจะทำให้เกิดวงวน", + "connectSameNode": "โหนดไม่สามารถเชื่อมต่อกับตัวเองได้", + "fixIssuesFirst": "แก้ไขปัญหาที่รายงานก่อนเรียกใช้", + "runFailed": "เรียกใช้ไม่สำเร็จ", + "runFinished": "เรียกใช้เสร็จสิ้น — เพิ่มเอาต์พุตแล้ว {{outputs}} รายการ", + "savedLog": "บันทึกแบบจำลองลงในโครงการแล้ว", + "exportedLog": "ส่งออก {{name}} แล้ว", + "importedLog": "นำเข้าแบบจำลองที่มี {{nodes}} โหนดแล้ว", + "importFailed": "นำเข้าไม่สำเร็จ", + "importInvalid": "ไฟล์นั้นไม่มีกราฟของแบบจำลอง", + "rasterOutputUnsupported": "\"{{name}}\" เป็นผลลัพธ์แรสเตอร์ซึ่งรุ่นนี้ไม่สามารถเพิ่มลงในแผนที่ได้" }, "parameterField": { "selectLayer": "เลือกเลเยอร์...", @@ -5402,7 +5426,8 @@ "pythonConsole": "คอนโซล Python", "sqlWorkspace": "พื้นที่ทำงาน SQL", "assistant": "ผู้ช่วย", - "statusBar": "แถบสถานะ" + "statusBar": "แถบสถานะ", + "modelBuilder": "ตัวสร้างแบบจำลอง" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 6c147994b0..95f65aa797 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -2543,7 +2543,7 @@ "duckdbLayer": "DuckDB Katmanı", "whitebox": "Whitebox", "geocode": "Adresleri Coğrafi Kodla", - "modelBuilder": "Toplu İşler & Modeller", + "modelBuilder": "Model Oluşturucu", "processingHistory": "Geçmiş", "conversion": "Dönüştürme", "vector": "Vektör", @@ -2710,7 +2710,8 @@ "projectName": "Proje adı", "storymapEllipsis": "Hikaye Haritası...", "pointerElevationNoticeTitle": "Rakım genel bir servis kullanır", - "pointerElevationNoticeDesc": "3B arazi açıkken rakım haritanın kendisinden hesaplanır ve hiçbir veri gönderilmez. 3B arazi yokken genel Open-Meteo API'si sorgulanır ve imlecin altındaki koordinatlar cihazınızdan çıkar." + "pointerElevationNoticeDesc": "3B arazi açıkken rakım haritanın kendisinden hesaplanır ve hiçbir veri gönderilmez. 3B arazi yokken genel Open-Meteo API'si sorgulanır ve imlecin altındaki koordinatlar cihazınızdan çıkar.", + "batchTools": "Toplu araçlar" }, "plugin": { "maplibre-gl-annotations": "Ek açıklamalar", @@ -4021,38 +4022,61 @@ "count_other": "{{count}} çalışma kaydedildi", "toolUnavailable": "\"{{toolId}}\" aracı artık kullanılamıyor" }, - "modelBuilder": { - "moveStepUp": "Adımı yukarı taşı", - "moveStepDown": "Adımı aşağı taşı", - "removeStep": "Adımı kaldır", - "title": "Toplu İşlem ve Modeller", - "description": "Bir vektör aracını birçok katman üzerinde çalıştırın ya da araçları projenizle birlikte kaydedilen yeniden kullanılabilir bir modelde zincirleyin.", - "tabBatch": "Toplu işlem", - "tabModels": "Modeller", - "outputPlaceholder": "Çıktı burada görünecek.", + "batchTools": { + "title": "Toplu araçlar", + "description": "Tek bir vektör aracını birçok katmanda aynı anda çalıştırın.", "tool": "Araç", "sharedParameters": "Ortak parametreler", "noExtraParameters": "Bu aracın ek parametresi yok.", "inputLayers": "Girdi katmanları", - "selectAll": "Tümünü seç", "clearSelection": "Temizle", + "selectAll": "Tümünü seç", "noCompatibleLayers": "Uyumlu GeoJSON katmanı yok.", - "newModel": "Yeni model", - "noSavedModels": "Henüz kaydedilmiş model yok.", - "untitledModel": "Adsız model", + "outputPlaceholder": "Çıktı burada görünecek." + }, + "modelBuilder": { + "title": "Model Oluşturucu", + "description": "Araçları tuvale sürükleyin ve bunları bir işleme modeline bağlayın.", "modelName": "Model adı", - "emptyPipelineHint": "İş hattını kurmaya başlamak için bir adım ekleyin. İlk adım bir girdi katmanı okur; sonraki her adım bir öncekinin çıktısını alır.", - "addStep": "Adım ekle", - "runModel": "Modeli çalıştır", - "deleteModel": "Sil", - "canvas": "Mekansal iş akışı tuvali", - "importPipeline": "İşlem hattını içe aktar", - "exportPipeline": "İşlem hattını dışa aktar", - "stepKindTransform": "Dönüşüm", - "canvasEmpty": "Henüz adım yok — iş akışını oluşturmak için bir adım ekleyin.", - "inputPreviousStep": "Girdi: ← önceki adımın çıktısı", - "unknownTool": "Bilinmeyen araç \"{{id}}\"", - "noParameters": "Parametre yok." + "modelNamePlaceholder": "Adsız model", + "untitledModel": "Adsız model", + "newModel": "Yeni", + "runModel": "Çalıştır", + "importModel": "İçe aktar", + "exportModel": "Dışa aktar", + "savedModels": "Kayıtlı modeller", + "loadModelPlaceholder": "Kayıtlı bir model yükle...", + "searchTools": "Araç ara", + "loadingTools": "Araçlar yükleniyor...", + "noToolsMatch": "Aramanızla eşleşen araç yok.", + "addInputNode": "+ Girdi", + "addOutputNode": "+ Çıktı", + "canvasEmpty": "Başlamak için paletten bir araç sürükleyin.", + "inputNode": "Girdi", + "outputNode": "Çıktı", + "inputPort": "Girdi: {{port}}", + "outputPort": "Çıktı: {{port}}", + "removeConnection": "Bağlantıyı kaldır", + "removeNode": "Düğümü kaldır", + "resizePanel": "Paneli yeniden boyutlandır", + "selectNodeHint": "Ayarlarını düzenlemek için bir düğüm seçin.", + "sourceLayer": "Kaynak katman", + "chooseLayer": "Bir katman seçin...", + "resultName": "Sonuç adı", + "resultNamePlaceholder": "Model çıktısı", + "noParameters": "Parametre yok.", + "outputPlaceholder": "İletiler burada görünür.", + "connectCycle": "Bu bağlantı bir döngü oluşturur.", + "connectSameNode": "Bir düğüm kendisine bağlanamaz.", + "fixIssuesFirst": "Çalıştırmadan önce bildirilen sorunları giderin.", + "runFailed": "Çalıştırma başarısız", + "runFinished": "Çalıştırma bitti — {{outputs}} çıktı eklendi.", + "savedLog": "Model projeye kaydedildi.", + "exportedLog": "{{name}} dışa aktarıldı", + "importedLog": "{{nodes}} düğümlü bir model içe aktarıldı.", + "importFailed": "İçe aktarma başarısız", + "importInvalid": "Bu dosya bir model grafiği içermiyor.", + "rasterOutputUnsupported": "\"{{name}}\" bir raster sonucudur; bu sürüm bunu haritaya ekleyemez." }, "parameterField": { "selectLayer": "Bir katman seçin...", @@ -5482,7 +5506,8 @@ "pythonConsole": "Python konsolu", "sqlWorkspace": "SQL çalışma alanı", "assistant": "Asistan", - "statusBar": "Durum çubuğu" + "statusBar": "Durum çubuğu", + "modelBuilder": "Model Oluşturucu" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index 664cb47770..e55650cd2e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -2505,7 +2505,7 @@ "duckdbLayer": "Lớp DuckDB", "whitebox": "Whitebox", "geocode": "Mã hóa địa lý địa chỉ", - "modelBuilder": "Xử lý hàng loạt & Mô hình", + "modelBuilder": "Trình dựng mô hình", "selectByExpressionEllipsis": "Chọn theo biểu thức...", "selectByLocationEllipsis": "Chọn theo vị trí...", "zoomToSelection": "Thu phóng để lựa chọn", @@ -2659,7 +2659,8 @@ "projectName": "Tên dự án", "storymapEllipsis": "Bản đồ câu chuyện...", "pointerElevationNoticeTitle": "Độ cao sử dụng dịch vụ độ cao công cộng", - "pointerElevationNoticeDesc": "Việc đọc độ cao của thanh trạng thái được phân giải từ địa hình 3D của chính bản đồ khi tính năng này được bật, tính năng này sẽ không gửi đi đâu cả. Nếu không có địa hình 3D, nó sẽ quay trở lại API độ cao Open-Meteo công khai và tọa độ dưới con trỏ sẽ rời khỏi thiết bị của bạn cho những yêu cầu đó." + "pointerElevationNoticeDesc": "Việc đọc độ cao của thanh trạng thái được phân giải từ địa hình 3D của chính bản đồ khi tính năng này được bật, tính năng này sẽ không gửi đi đâu cả. Nếu không có địa hình 3D, nó sẽ quay trở lại API độ cao Open-Meteo công khai và tọa độ dưới con trỏ sẽ rời khỏi thiết bị của bạn cho những yêu cầu đó.", + "batchTools": "Công cụ hàng loạt" }, "networkTool": { "isochrone": "Isochrone / khu vực dịch vụ", @@ -4040,38 +4041,61 @@ "copyLinkCopied": "Đã sao chép!", "vectorUnitsNote": "Các lớp vectơ được đọc dưới dạng WGS84, do đó, các giá trị khoảng cách, khoảng cách và dung sai được tính bằng độ chứ không phải mét. 1° là khoảng 111 km và 0,001° là khoảng 111 m." }, - "modelBuilder": { - "noSavedModels": "Chọn một lớp...", - "untitledModel": "Người mẫu không có tiêu đề", - "modelName": "Tên mẫu", - "emptyPipelineHint": "Thêm một bước để bắt đầu xây dựng quy trình. Bước đầu tiên đọc lớp đầu vào; mỗi bước sau sẽ nhận được đầu ra của bước trước.", - "addStep": "Thêm bước", - "runModel": "Chạy mô hình", - "deleteModel": "Xóa bỏ", - "canvas": "Khung quy trình không gian", - "importPipeline": "Nhập quy trình", - "exportPipeline": "Xuất quy trình", - "stepKindTransform": "Biến đổi", - "canvasEmpty": "Chưa có bước nào — thêm một bước để dựng quy trình.", - "inputPreviousStep": "Đầu vào: ← đầu ra của bước trước", - "unknownTool": "Công cụ không xác định \"{{id}}\"", - "noParameters": "Không có tham số.", - "moveStepUp": "Tiến bước lên", - "moveStepDown": "Di chuyển bước xuống", - "removeStep": "Xóa bước", - "title": "Tìm kiếm hệ quy chiếu tọa độ", - "description": "Chạy công cụ vectơ trên nhiều lớp hoặc xâu chuỗi các công cụ thành mô hình có thể sử dụng lại được lưu cùng với dự án của bạn.", - "tabBatch": "Lô", - "tabModels": "Người mẫu", - "outputPlaceholder": "Đầu ra sẽ xuất hiện ở đây.", + "batchTools": { + "title": "Công cụ hàng loạt", + "description": "Chạy một công cụ vector trên nhiều lớp cùng lúc.", "tool": "Dụng cụ", "sharedParameters": "Thông số được chia sẻ", "noExtraParameters": "Công cụ này không có tham số bổ sung.", "inputLayers": "Lớp đầu vào", - "selectAll": "Chọn tất cả", "clearSelection": "Thông thoáng", + "selectAll": "Chọn tất cả", "noCompatibleLayers": "Không có lớp GeoJSON tương thích.", - "newModel": "Mẫu mới" + "outputPlaceholder": "Đầu ra sẽ xuất hiện ở đây." + }, + "modelBuilder": { + "title": "Trình dựng mô hình", + "description": "Kéo công cụ vào khung vẽ và nối chúng thành một mô hình xử lý.", + "modelName": "Tên mô hình", + "modelNamePlaceholder": "Mô hình chưa đặt tên", + "untitledModel": "Mô hình chưa đặt tên", + "newModel": "Mới", + "runModel": "Chạy", + "importModel": "Nhập", + "exportModel": "Xuất", + "savedModels": "Mô hình đã lưu", + "loadModelPlaceholder": "Tải một mô hình đã lưu...", + "searchTools": "Tìm công cụ", + "loadingTools": "Đang tải công cụ...", + "noToolsMatch": "Không có công cụ nào khớp với tìm kiếm của bạn.", + "addInputNode": "+ Đầu vào", + "addOutputNode": "+ Đầu ra", + "canvasEmpty": "Kéo một công cụ từ bảng công cụ để bắt đầu dựng.", + "inputNode": "Đầu vào", + "outputNode": "Đầu ra", + "inputPort": "Đầu vào: {{port}}", + "outputPort": "Đầu ra: {{port}}", + "removeConnection": "Xóa kết nối", + "removeNode": "Xóa nút", + "resizePanel": "Đổi kích thước bảng", + "selectNodeHint": "Chọn một nút để chỉnh sửa thiết lập của nó.", + "sourceLayer": "Lớp nguồn", + "chooseLayer": "Chọn một lớp...", + "resultName": "Tên kết quả", + "resultNamePlaceholder": "Đầu ra mô hình", + "noParameters": "Không có tham số.", + "outputPlaceholder": "Thông báo sẽ hiển thị ở đây.", + "connectCycle": "Kết nối đó sẽ tạo thành vòng lặp.", + "connectSameNode": "Một nút không thể tự nối với chính nó.", + "fixIssuesFirst": "Hãy khắc phục các vấn đề được báo trước khi chạy.", + "runFailed": "Chạy thất bại", + "runFinished": "Chạy xong — đã thêm {{outputs}} đầu ra.", + "savedLog": "Đã lưu mô hình vào dự án.", + "exportedLog": "Đã xuất {{name}}", + "importedLog": "Đã nhập một mô hình có {{nodes}} nút.", + "importFailed": "Nhập thất bại", + "importInvalid": "Tệp đó không chứa đồ thị mô hình.", + "rasterOutputUnsupported": "\"{{name}}\" là kết quả raster mà bản dựng này không thể thêm vào bản đồ." }, "parameterField": { "selectLayer": "Sao chép liên kết có thể chia sẻ để mở công cụ này với cài đặt hiện tại", @@ -5376,7 +5400,8 @@ "selectionPanels": "Bảng lựa chọn", "sunSimulationPanel": "Bảng mô phỏng mặt trời", "routeAnimationPanel": "Bảng điều khiển hoạt ảnh tuyến đường", - "flightSimulatorPanel": "Bảng mô phỏng chuyến bay" + "flightSimulatorPanel": "Bảng mô phỏng chuyến bay", + "modelBuilder": "Trình dựng mô hình" }, "workspaceTitle": "Không gian làm việc của bản đồ GeoLibre" }, diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 14bfcc5a76..2b68a68488 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -2498,7 +2498,7 @@ "duckdbLayer": "DuckDB 图层", "whitebox": "Whitebox", "geocode": "地理编码地址", - "modelBuilder": "批处理与模型", + "modelBuilder": "模型构建器", "processingHistory": "历史记录", "conversion": "转换", "vector": "矢量", @@ -2662,7 +2662,8 @@ "projectName": "项目名称", "storymapEllipsis": "故事地图...", "pointerElevationNoticeTitle": "高程会使用公共服务", - "pointerElevationNoticeDesc": "启用三维地形时,高程直接由地图计算,不会发送任何数据。未启用时将查询公共 Open-Meteo API,指针下方的坐标会离开您的设备。" + "pointerElevationNoticeDesc": "启用三维地形时,高程直接由地图计算,不会发送任何数据。未启用时将查询公共 Open-Meteo API,指针下方的坐标会离开您的设备。", + "batchTools": "批处理工具" }, "plugin": { "maplibre-gl-annotations": "注释", @@ -3954,38 +3955,61 @@ "count_other": "已记录 {{count}} 次运行", "toolUnavailable": "工具“{{toolId}}”已不可用" }, - "modelBuilder": { - "moveStepUp": "上移步骤", - "moveStepDown": "下移步骤", - "removeStep": "移除步骤", - "title": "批处理与模型", - "description": "对多个图层运行同一个矢量工具,或将多个工具串联成可复用的模型并随项目保存。", - "tabBatch": "批处理", - "tabModels": "模型", - "outputPlaceholder": "输出将显示在此处。", + "batchTools": { + "title": "批处理工具", + "description": "对多个图层一次性运行同一个矢量工具。", "tool": "工具", "sharedParameters": "共享参数", "noExtraParameters": "此工具没有额外参数。", "inputLayers": "输入图层", - "selectAll": "全选", "clearSelection": "清除", + "selectAll": "全选", "noCompatibleLayers": "没有兼容的 GeoJSON 图层。", - "newModel": "新建模型", - "noSavedModels": "尚无已保存的模型。", - "untitledModel": "未命名模型", + "outputPlaceholder": "输出将显示在此处。" + }, + "modelBuilder": { + "title": "模型构建器", + "description": "将工具拖到画布上,并将它们连接成一个处理模型。", "modelName": "模型名称", - "emptyPipelineHint": "添加一个步骤以开始构建流程。第一步读取输入图层;后续每一步接收上一步的输出。", - "addStep": "添加步骤", - "runModel": "运行模型", - "deleteModel": "删除", - "canvas": "空间工作流画布", - "importPipeline": "导入管道", - "exportPipeline": "导出管道", - "stepKindTransform": "转换", - "canvasEmpty": "尚无步骤 — 添加一个以构建工作流。", - "inputPreviousStep": "输入:← 上一步的输出", - "unknownTool": "未知工具“{{id}}”", - "noParameters": "没有参数。" + "modelNamePlaceholder": "未命名模型", + "untitledModel": "未命名模型", + "newModel": "新建", + "runModel": "运行", + "importModel": "导入", + "exportModel": "导出", + "savedModels": "已保存的模型", + "loadModelPlaceholder": "加载已保存的模型…", + "searchTools": "搜索工具", + "loadingTools": "正在加载工具…", + "noToolsMatch": "没有与搜索匹配的工具。", + "addInputNode": "+ 输入", + "addOutputNode": "+ 输出", + "canvasEmpty": "从工具面板拖入一个工具即可开始搭建。", + "inputNode": "输入", + "outputNode": "输出", + "inputPort": "输入:{{port}}", + "outputPort": "输出:{{port}}", + "removeConnection": "删除连接", + "removeNode": "删除节点", + "resizePanel": "调整面板大小", + "selectNodeHint": "选择一个节点以编辑其设置。", + "sourceLayer": "源图层", + "chooseLayer": "选择图层…", + "resultName": "结果名称", + "resultNamePlaceholder": "模型输出", + "noParameters": "无参数。", + "outputPlaceholder": "消息将显示在此处。", + "connectCycle": "该连接会形成环路。", + "connectSameNode": "节点不能连接到自身。", + "fixIssuesFirst": "请先修复所报告的问题,然后再运行。", + "runFailed": "运行失败", + "runFinished": "运行完成 — 已添加 {{outputs}} 个输出。", + "savedLog": "模型已保存到项目中。", + "exportedLog": "已导出 {{name}}", + "importedLog": "已导入包含 {{nodes}} 个节点的模型。", + "importFailed": "导入失败", + "importInvalid": "该文件不包含模型图。", + "rasterOutputUnsupported": "“{{name}}”是栅格结果,此版本无法将其添加到地图。" }, "parameterField": { "selectLayer": "选择一个图层...", @@ -5402,7 +5426,8 @@ "pythonConsole": "Python 控制台", "sqlWorkspace": "SQL 工作区", "assistant": "助手", - "statusBar": "状态栏" + "statusBar": "状态栏", + "modelBuilder": "模型构建器" } }, "attributeStats": { diff --git a/apps/geolibre-desktop/src/lib/model-graph-edit.ts b/apps/geolibre-desktop/src/lib/model-graph-edit.ts new file mode 100644 index 0000000000..22096f686b --- /dev/null +++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts @@ -0,0 +1,281 @@ +import type { + ModelGraphEdge, + ModelGraphNode, + ModelGraphNodeKind, + ModelToolProvider, + ProcessingModelGraph, +} from "@geolibre/core"; +import type { ModelToolDescriptor } from "@geolibre/processing"; + +/** An empty canvas. */ +export function emptyModelGraph(): ProcessingModelGraph { + return { nodes: [], edges: [] }; +} + +/** Card footprint used for collision checks, matching the canvas renderer. */ +export const NODE_WIDTH = 168; +export const NODE_HEIGHT = 64; +const NODE_GAP = 16; + +/** + * Nudge a preferred position down until the card would not overlap an existing + * one. + * + * Overlapping cards do not just look untidy: the one painted on top swallows + * the hit-test for the other's connector dots, so a port underneath cannot be + * wired at all. Placement therefore has to guarantee a clear footprint rather + * than merely stagger by a few pixels. + * + * @param graph The current graph. + * @param preferred Where the caller would like the node to go. + * @returns The first free position at or below `preferred`. + */ +export function findFreePosition( + graph: ProcessingModelGraph, + preferred: { x: number; y: number }, +): { x: number; y: number } { + const overlaps = (x: number, y: number): boolean => + graph.nodes.some( + (node) => + x < node.x + NODE_WIDTH + NODE_GAP && + x + NODE_WIDTH + NODE_GAP > node.x && + y < node.y + NODE_HEIGHT + NODE_GAP && + y + NODE_HEIGHT + NODE_GAP > node.y, + ); + let { x, y } = preferred; + // Bounded so a pathological graph cannot spin here; past that the user can + // drag the node somewhere sensible themselves. + for (let attempt = 0; attempt < 200 && overlaps(x, y); attempt++) { + y += NODE_HEIGHT + NODE_GAP; + } + return { x, y }; +} + +/** + * Add an `input` or `output` node at a canvas position. + * + * @param graph The current graph. + * @param kind Which of the two non-tool node kinds to add. + * @param position Canvas coordinates for the new node. + * @param createId Id factory. + * @returns The updated graph and the new node's id. + */ +export function addDataNode( + graph: ProcessingModelGraph, + kind: Extract, + position: { x: number; y: number }, + createId: () => string, +): { graph: ProcessingModelGraph; nodeId: string } { + const nodeId = createId(); + const free = findFreePosition(graph, position); + const node: ModelGraphNode = { + id: nodeId, + kind, + x: free.x, + y: free.y, + ...(kind === "output" ? { name: "" } : {}), + }; + return { graph: { ...graph, nodes: [...graph.nodes, node] }, nodeId }; +} + +/** + * Add a tool node, seeded with the descriptor's documented parameter defaults so + * a freshly dropped node is runnable without opening every field first. + * + * @param graph The current graph. + * @param descriptor The palette entry being dropped. + * @param position Canvas coordinates for the new node. + * @param createId Id factory. + * @returns The updated graph and the new node's id. + */ +export function addToolNode( + graph: ProcessingModelGraph, + descriptor: ModelToolDescriptor, + position: { x: number; y: number }, + createId: () => string, +): { graph: ProcessingModelGraph; nodeId: string } { + const parameters: Record = {}; + for (const param of descriptor.parameters) { + if (param.default !== undefined) parameters[param.id] = param.default; + } + const nodeId = createId(); + const free = findFreePosition(graph, position); + const node: ModelGraphNode = { + id: nodeId, + kind: "tool", + x: free.x, + y: free.y, + provider: descriptor.provider as ModelToolProvider, + toolId: descriptor.toolId, + parameters, + }; + return { graph: { ...graph, nodes: [...graph.nodes, node] }, nodeId }; +} + +/** Move a node to a new canvas position. */ +export function moveNode( + graph: ProcessingModelGraph, + nodeId: string, + position: { x: number; y: number }, +): ProcessingModelGraph { + return { + ...graph, + nodes: graph.nodes.map((node) => + node.id === nodeId ? { ...node, x: position.x, y: position.y } : node, + ), + }; +} + +/** Remove a node together with every edge touching it, so no edge is orphaned. */ +export function removeNode(graph: ProcessingModelGraph, nodeId: string): ProcessingModelGraph { + return { + nodes: graph.nodes.filter((node) => node.id !== nodeId), + edges: graph.edges.filter((edge) => edge.from !== nodeId && edge.to !== nodeId), + }; +} + +/** Remove a single connection. */ +export function removeEdge(graph: ProcessingModelGraph, edgeId: string): ProcessingModelGraph { + return { ...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId) }; +} + +/** Merge new values into a node's stored parameters. */ +export function setNodeParameter( + graph: ProcessingModelGraph, + nodeId: string, + paramId: string, + value: unknown, +): ProcessingModelGraph { + return { + ...graph, + nodes: graph.nodes.map((node) => + node.id === nodeId + ? { ...node, parameters: { ...(node.parameters ?? {}), [paramId]: value } } + : node, + ), + }; +} + +/** Set an `input` node's source layer, or an `output` node's result name. */ +export function setNodeField( + graph: ProcessingModelGraph, + nodeId: string, + field: "layerId" | "name", + value: string, +): ProcessingModelGraph { + return { + ...graph, + nodes: graph.nodes.map((node) => (node.id === nodeId ? { ...node, [field]: value } : node)), + }; +} + +/** Why {@link connectNodes} refused a connection. */ +export type ConnectRejection = "same-node" | "cycle"; + +/** + * Connect an output port to an input port. + * + * An input port holds one value, so an existing edge into the same port is + * replaced rather than added alongside — dragging a new connection onto a filled + * port is how a user rewires it. A connection that would close a loop is + * refused, since the graph could never be ordered. + * + * @param graph The current graph. + * @param from Source node id and output port id. + * @param to Target node id and input port id. + * @param createId Id factory. + * @returns The updated graph, or a rejection reason when the edge is illegal. + */ +export function connectNodes( + graph: ProcessingModelGraph, + from: { nodeId: string; portId: string }, + to: { nodeId: string; portId: string }, + createId: () => string, +): { graph: ProcessingModelGraph } | { rejected: ConnectRejection } { + if (from.nodeId === to.nodeId) return { rejected: "same-node" }; + if (createsCycle(graph, from.nodeId, to.nodeId)) return { rejected: "cycle" }; + const edge: ModelGraphEdge = { + id: createId(), + from: from.nodeId, + fromPort: from.portId, + to: to.nodeId, + toPort: to.portId, + }; + const edges = graph.edges.filter( + (existing) => !(existing.to === to.nodeId && existing.toPort === to.portId), + ); + return { graph: { ...graph, edges: [...edges, edge] } }; +} + +/** + * Whether adding `from → to` would close a loop, i.e. whether `from` is already + * reachable from `to`. + * + * @param graph The current graph. + * @param from Proposed source node id. + * @param to Proposed target node id. + * @returns True when the edge must be refused. + */ +export function createsCycle(graph: ProcessingModelGraph, from: string, to: string): boolean { + const outgoing = new Map(); + for (const edge of graph.edges) { + const list = outgoing.get(edge.from) ?? []; + list.push(edge.to); + outgoing.set(edge.from, list); + } + const stack = [to]; + const seen = new Set(); + while (stack.length > 0) { + const current = stack.pop() as string; + if (current === from) return true; + if (seen.has(current)) continue; + seen.add(current); + stack.push(...(outgoing.get(current) ?? [])); + } + return false; +} + +/** + * Lay a freshly imported graph out on a grid when its nodes carry no usable + * positions — a hand-written or older pipeline file would otherwise stack every + * node at the origin. + * + * @param graph The imported graph. + * @returns The graph, with positions filled in only if they were all at 0,0. + */ +export function autoLayout(graph: ProcessingModelGraph): ProcessingModelGraph { + const placed = graph.nodes.some((node) => node.x !== 0 || node.y !== 0); + if (placed || graph.nodes.length === 0) return graph; + const COLUMN = 240; + const ROW = 120; + // Depth from the sources, so the layout reads left-to-right along the flow. + const depth = new Map(); + const incoming = new Map(); + for (const edge of graph.edges) { + const list = incoming.get(edge.to) ?? []; + list.push(edge.from); + incoming.set(edge.to, list); + } + const resolveDepth = (nodeId: string, seen: Set): number => { + if (depth.has(nodeId)) return depth.get(nodeId) as number; + if (seen.has(nodeId)) return 0; + seen.add(nodeId); + const parents = incoming.get(nodeId) ?? []; + const value = parents.length + ? Math.max(...parents.map((parent) => resolveDepth(parent, seen) + 1)) + : 0; + depth.set(nodeId, value); + return value; + }; + for (const node of graph.nodes) resolveDepth(node.id, new Set()); + const perColumn = new Map(); + return { + ...graph, + nodes: graph.nodes.map((node) => { + const column = depth.get(node.id) ?? 0; + const row = perColumn.get(column) ?? 0; + perColumn.set(column, row + 1); + return { ...node, x: 40 + column * COLUMN, y: 40 + row * ROW }; + }), + }; +} diff --git a/apps/geolibre-desktop/src/lib/model-tool-catalog.ts b/apps/geolibre-desktop/src/lib/model-tool-catalog.ts new file mode 100644 index 0000000000..39865e0845 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/model-tool-catalog.ts @@ -0,0 +1,213 @@ +import type { + AlgorithmParameter, + ModelToolDescriptor, + ModelToolPort, + ProcessingAlgorithm, + WhiteboxTool, + WhiteboxToolParameter, +} from "@geolibre/processing"; +import { parameterKind } from "./whitebox-param-kind"; + +/** Palette group for Whitebox tools that arrive without a category. */ +const UNCATEGORIZED = "Other"; + +/** + * Build the palette key for a tool. Whitebox and the client vector registry both + * define e.g. `buffer`, so the provider has to be part of the identity. + * + * @param provider Which registry the tool comes from. + * @param toolId The tool's id within that registry. + * @returns The globally unique key used by the palette and by saved nodes. + */ +export function modelToolKey(provider: "vector" | "whitebox", toolId: string): string { + return `${provider}:${toolId}`; +} + +/** + * Adapt a client vector algorithm to a Model Builder descriptor. + * + * Its `type: "layer"` parameters become input ports (wiring an edge and picking + * a layer by hand write the same slot, so they keep their parameter ids), and + * the tool gains the single vector output port the client runner produces. + * + * @param algorithm The registry algorithm. + * @returns The descriptor the canvas and graph runner use. + */ +export function vectorToolDescriptor(algorithm: ProcessingAlgorithm): ModelToolDescriptor { + const inputs: ModelToolPort[] = []; + const parameters: AlgorithmParameter[] = []; + for (const param of algorithm.parameters) { + if (param.type === "layer") { + inputs.push({ + id: param.id, + label: param.label, + kind: "vector", + required: param.required, + }); + // Still offered in the properties panel, so a single-node model can name a + // project layer without drawing an input node for it. + parameters.push(param); + continue; + } + parameters.push(param); + } + return { + key: modelToolKey("vector", algorithm.id), + provider: "vector", + toolId: algorithm.id, + name: algorithm.name, + group: algorithm.group ?? UNCATEGORIZED, + description: algorithm.description, + inputs, + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters, + }; +} + +/** Map a Whitebox scalar parameter onto the field type the properties panel renders. */ +function whiteboxScalarParameter( + param: WhiteboxToolParameter, + kind: string, +): AlgorithmParameter | null { + const base = { + id: param.name, + label: param.name, + required: param.required, + description: param.description, + default: param.default, + }; + if (kind === "bool") return { ...base, type: "boolean" }; + if (kind === "int" || kind === "double") return { ...base, type: "number" }; + if (kind === "enum") { + return { + ...base, + type: "select", + options: (param.options ?? []).map((option) => ({ value: option, label: option })), + }; + } + if (kind === "string") return { ...base, type: "string" }; + // lidar_in / file_in / file_out and anything unrecognized: a path the user + // supplies rather than something an edge can carry, since a model value is + // only ever vector or raster. + return { ...base, type: "path" }; +} + +/** + * Adapt a Whitebox (or GeoLibre-authored WASM) tool manifest to a Model Builder + * descriptor. + * + * `raster_in`/`vector_in` parameters become typed input ports and + * `raster_out`/`vector_out` become output ports, which is what lets a Whitebox + * node sit in the same graph as a client vector node. LiDAR and file parameters + * stay plain fields: a model value is only ever vector or raster, so an edge + * could not carry them. + * + * @param tool The merged catalog/WASM manifest. + * @returns The descriptor, or `null` for a tool with no output port — nothing + * downstream could consume it, so it would be dead weight on the canvas. + */ +export function whiteboxToolDescriptor(tool: WhiteboxTool): ModelToolDescriptor | null { + const inputs: ModelToolPort[] = []; + const outputs: ModelToolPort[] = []; + const parameters: AlgorithmParameter[] = []; + for (const param of tool.params ?? []) { + const kind = parameterKind(param); + if (kind === "raster_in" || kind === "vector_in") { + inputs.push({ + id: param.name, + label: param.name, + kind: kind === "raster_in" ? "raster" : "vector", + required: param.required, + }); + continue; + } + if (kind === "raster_out" || kind === "vector_out") { + outputs.push({ + id: param.name, + label: param.name, + kind: kind === "raster_out" ? "raster" : "vector", + }); + continue; + } + const mapped = whiteboxScalarParameter(param, kind); + if (mapped) parameters.push(mapped); + } + if (outputs.length === 0) return null; + const group = + tool.taxonomy_category?.trim() || + tool.category?.trim() || + (tool.source ? "GeoLibre" : UNCATEGORIZED); + return { + key: modelToolKey("whitebox", tool.id), + provider: "whitebox", + toolId: tool.id, + name: tool.display_name?.trim() || tool.id, + group, + description: tool.summary, + inputs, + outputs, + parameters, + }; +} + +/** + * Build the combined palette from both registries. + * + * Locked ("pro"-tier) Whitebox tools are dropped: they cannot run, so offering + * them on the canvas would only produce a model that fails at the last step. + * + * @param vectorTools The client vector algorithm registry. + * @param whiteboxTools Merged Whitebox catalog + WASM manifests. + * @returns Descriptors sorted by group then name, ready for the palette. + */ +export function buildModelToolCatalog( + vectorTools: ProcessingAlgorithm[], + whiteboxTools: WhiteboxTool[], +): ModelToolDescriptor[] { + const descriptors: ModelToolDescriptor[] = vectorTools.map(vectorToolDescriptor); + for (const tool of whiteboxTools) { + if (tool.locked) continue; + const descriptor = whiteboxToolDescriptor(tool); + if (descriptor) descriptors.push(descriptor); + } + descriptors.sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name)); + return descriptors; +} + +/** + * Group a descriptor list for the palette's collapsible sections, preserving the + * sorted order {@link buildModelToolCatalog} produced. + * + * @param descriptors The palette entries. + * @returns One entry per group, in first-seen order. + */ +export function groupModelTools( + descriptors: ModelToolDescriptor[], +): { group: string; tools: ModelToolDescriptor[] }[] { + const groups = new Map(); + for (const descriptor of descriptors) { + const list = groups.get(descriptor.group) ?? []; + list.push(descriptor); + groups.set(descriptor.group, list); + } + return [...groups].map(([group, tools]) => ({ group, tools })); +} + +/** + * Filter the palette by a free-text query, matching tool name, id and group so + * "terrain" finds a slope tool and "buffer" finds it under either provider. + * + * @param descriptors The palette entries. + * @param query The user's search text; blank returns everything. + * @returns The matching entries, in their original order. + */ +export function searchModelTools( + descriptors: ModelToolDescriptor[], + query: string, +): ModelToolDescriptor[] { + const needle = query.trim().toLowerCase(); + if (!needle) return descriptors; + return descriptors.filter((descriptor) => + `${descriptor.name} ${descriptor.toolId} ${descriptor.group}`.toLowerCase().includes(needle), + ); +} diff --git a/apps/geolibre-desktop/src/lib/ui-profile.ts b/apps/geolibre-desktop/src/lib/ui-profile.ts index b4a201805c..256ab407dc 100644 --- a/apps/geolibre-desktop/src/lib/ui-profile.ts +++ b/apps/geolibre-desktop/src/lib/ui-profile.ts @@ -459,6 +459,12 @@ export const MENU_ITEM_CATALOG: readonly MenuItemCatalogEntry[] = [ labelKey: "toolbar.item.geocode", tier: "intermediate", }, + { + id: "processing.batchTools", + menuId: "processing", + labelKey: "toolbar.item.batchTools", + tier: "advanced", + }, { id: "processing.modelBuilder", menuId: "processing", diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index a99c03aad7..f332123f93 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -27,7 +27,12 @@ import { type MapScaleUnit, type MapViewState, MAX_PROCESSING_HISTORY, + type ModelGraphEdge, + type ModelGraphNode, + type ModelGraphNodeKind, + type ModelToolProvider, type ProcessingModel, + type ProcessingModelGraph, type ProcessingRun, type ProcessingRunKind, type SecondaryMapView, @@ -660,11 +665,89 @@ export function normalizeModels(value: unknown): ProcessingModel[] | null { }); } seen.add(id); - models.push({ id, name: normalizeString(candidate.name), steps }); + const graph = normalizeModelGraph((candidate as { graph?: unknown }).graph); + models.push({ + id, + name: normalizeString(candidate.name), + steps, + ...(graph ? { graph } : {}), + }); } return models.length > 0 ? models : null; } +const MODEL_NODE_KINDS = new Set(["input", "tool", "output"]); +const MODEL_TOOL_PROVIDERS = new Set(["vector", "whitebox"]); + +/** Coerce an untrusted number to a finite canvas coordinate. */ +function normalizeCoordinate(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** + * Coerce an untrusted `graph` value into a {@link ProcessingModelGraph}. Drops + * nodes without a usable id or an unknown kind, de-duplicates node ids, and + * drops edges that do not connect two surviving nodes or that name an empty + * port. Self-edges are dropped too, since a node cannot feed itself. + * + * Structural validity beyond this (cycles, type mismatches, missing required + * inputs) is the runner's job — those depend on the tool registries, which the + * project layer deliberately does not import. + * + * @param value Raw `graph` value from the project JSON. + * @returns The normalized graph, or `null` when it has no usable nodes. + */ +export function normalizeModelGraph(value: unknown): ProcessingModelGraph | null { + if (!value || typeof value !== "object") return null; + const raw = value as Partial; + const nodes: ModelGraphNode[] = []; + const nodeIds = new Set(); + for (const entry of Array.isArray(raw.nodes) ? raw.nodes : []) { + if (!entry || typeof entry !== "object") continue; + const node = entry as Partial; + const nodeId = normalizeString(node.id).trim(); + const kind = node.kind as ModelGraphNodeKind; + if (!nodeId || nodeIds.has(nodeId) || !MODEL_NODE_KINDS.has(kind)) continue; + nodeIds.add(nodeId); + const layerId = normalizeString(node.layerId).trim(); + const toolId = normalizeString(node.toolId).trim(); + const name = normalizeString(node.name).trim(); + const provider = node.provider as ModelToolProvider; + nodes.push({ + id: nodeId, + kind, + x: normalizeCoordinate(node.x), + y: normalizeCoordinate(node.y), + ...(layerId ? { layerId } : {}), + ...(toolId ? { toolId } : {}), + ...(MODEL_TOOL_PROVIDERS.has(provider) ? { provider } : {}), + ...(node.parameters && typeof node.parameters === "object" && !Array.isArray(node.parameters) + ? { parameters: node.parameters as Record } + : {}), + ...(name ? { name } : {}), + }); + } + if (nodes.length === 0) return null; + + const edges: ModelGraphEdge[] = []; + const edgeIds = new Set(); + for (const entry of Array.isArray(raw.edges) ? raw.edges : []) { + if (!entry || typeof entry !== "object") continue; + const edge = entry as Partial; + const edgeId = normalizeString(edge.id).trim(); + const from = normalizeString(edge.from).trim(); + const to = normalizeString(edge.to).trim(); + const fromPort = normalizeString(edge.fromPort).trim(); + const toPort = normalizeString(edge.toPort).trim(); + if (!edgeId || edgeIds.has(edgeId)) continue; + if (!nodeIds.has(from) || !nodeIds.has(to) || from === to) continue; + if (!fromPort || !toPort) continue; + edgeIds.add(edgeId); + edges.push({ id: edgeId, from, fromPort, to, toPort }); + } + return { nodes, edges }; +} + const PROCESSING_RUN_KINDS = new Set([ "vector", "statistics", diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index bdd867cc19..371575a8a4 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -330,6 +330,9 @@ export interface AppState { // Story Map dialog is hidden so the user can pan/zoom/tilt the real map and // save the resulting camera back into this chapter (issue #775). storymapComposingId: string | null; + /** The Batch tools dialog (run one tool across many layers). */ + batchToolsOpen: boolean; + /** The Model Builder canvas panel (author a processing graph). */ modelBuilderOpen: boolean; /** Style Manager dialog visibility (issue #1294). */ styleManagerOpen: boolean; @@ -446,6 +449,7 @@ export interface AppState { setStorymapPanelOpen: (open: boolean) => void; setStorymapPresenting: (presenting: boolean, returnToEditor?: boolean) => void; setStorymapComposing: (chapterId: string | null) => void; + setBatchToolsOpen: (open: boolean) => void; setModelBuilderOpen: (open: boolean) => void; setProcessingHistoryOpen: (open: boolean) => void; /** Open/close Select by Expression, optionally preselecting a target layer. */ @@ -1043,6 +1047,7 @@ export const useAppStore = create()( storymapPresenting: false, storymapReturnToEditor: false, storymapComposingId: null, + batchToolsOpen: false, modelBuilderOpen: false, styleManagerOpen: false, processingHistoryOpen: false, @@ -1374,6 +1379,7 @@ export const useAppStore = create()( })), setStorymapComposing: (chapterId) => set((s) => ({ ui: { ...s.ui, storymapComposingId: chapterId } })), + setBatchToolsOpen: (open) => set((s) => ({ ui: { ...s.ui, batchToolsOpen: open } })), setModelBuilderOpen: (open) => set((s) => ({ ui: { ...s.ui, modelBuilderOpen: open } })), setProcessingHistoryOpen: (open) => set((s) => ({ ui: { ...s.ui, processingHistoryOpen: open } })), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 90941e3c5b..2327225f8a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1584,14 +1584,94 @@ export interface ProcessingModelStep { } /** - * A reusable, sequential processing pipeline ("model" in QGIS Graphical Modeler - * / ArcGIS ModelBuilder terms). Steps run in order; each step's result feeds the - * next. Saved in the project file so it can be reloaded and re-run. + * What flows along a model edge. Vector nodes exchange FeatureCollections; + * raster nodes exchange GeoTIFF bytes. A port declaring `"any"` accepts either + * and is resolved to a concrete kind at run time by whatever is wired into it. + */ +export type ModelPortKind = "vector" | "raster" | "any"; + +/** One connection point on a {@link ModelGraphNode}. */ +export interface ModelGraphPort { + /** + * Port id, unique within its node and direction. For a tool node's inputs + * this is the underlying tool parameter id, so wiring an edge and setting the + * parameter by hand are the same operation. + */ + id: string; + label: string; + kind: ModelPortKind; + /** Inputs only: the run fails when nothing is wired in and no value is set. */ + required?: boolean; +} + +/** + * What a node does. `input` sources an existing project layer, `tool` runs a + * processing algorithm, and `output` names a result to add back to the map. + */ +export type ModelGraphNodeKind = "input" | "tool" | "output"; + +/** One node on the Model Builder canvas. */ +export interface ModelGraphNode { + /** Stable id, unique within the graph; referenced by {@link ModelGraphEdge}. */ + id: string; + kind: ModelGraphNodeKind; + /** Canvas position in graph coordinates (unscaled by zoom). */ + x: number; + y: number; + /** `input` nodes: the project layer id this node sources. */ + layerId?: string; + /** + * `tool` nodes: the tool's id within {@link provider}'s registry. Kept + * separate from the provider so the same short id can exist in both. + */ + toolId?: string; + /** `tool` nodes: which registry resolves {@link toolId}. */ + provider?: ModelToolProvider; + /** `tool` nodes: parameter values for everything not supplied by an edge. */ + parameters?: Record; + /** `output` nodes: the layer name given to the result added to the map. */ + name?: string; +} + +/** + * A directed connection from one node's output port to another node's input + * port. Ports are named, so a tool with several inputs (Clip's target and + * overlay, say) wires each one unambiguously. + */ +export interface ModelGraphEdge { + id: string; + from: string; + fromPort: string; + to: string; + toPort: string; +} + +/** The node-and-edge graph authored on the Model Builder canvas. */ +export interface ProcessingModelGraph { + nodes: ModelGraphNode[]; + edges: ModelGraphEdge[]; +} + +/** Which registry a {@link ModelGraphNode.toolId} is resolved against. */ +export type ModelToolProvider = "vector" | "whitebox"; + +/** + * A reusable processing pipeline ("model" in QGIS Graphical Modeler / ArcGIS + * ModelBuilder terms), saved in the project file so it can be reloaded and + * re-run. + * + * Two shapes coexist. {@link steps} is the original strictly linear chain, and + * remains the only thing older builds understand. {@link graph} is the + * Model Builder's directed graph, which supports multi-input tools, branches + * and merges. When both are present `graph` wins; a model saved by the canvas + * also writes a `steps` projection whenever its graph happens to be a single + * chain, so older builds can still run it. */ export interface ProcessingModel { id: string; name: string; steps: ProcessingModelStep[]; + graph?: ProcessingModelGraph; } /** diff --git a/packages/processing/src/index.ts b/packages/processing/src/index.ts index 1b4ede0c5d..f85278a02e 100644 --- a/packages/processing/src/index.ts +++ b/packages/processing/src/index.ts @@ -339,3 +339,20 @@ export { type ViewshedObserver, type ViewshedResult, } from "./terrain-viewshed"; +export { + INPUT_NODE_PORT, + OUTPUT_NODE_PORT, + graphToLinearSteps, + portKindsCompatible, + runModelGraph, + topologicalOrder, + validateModelGraph, + type DescriptorResolver, + type ModelGraphIssue, + type ModelGraphRunResult, + type ModelToolDescriptor, + type ModelToolExecutor, + type ModelToolPort, + type ModelValue, + type RunModelGraphOptions, +} from "./model-graph"; diff --git a/packages/processing/src/model-graph.ts b/packages/processing/src/model-graph.ts new file mode 100644 index 0000000000..9003bebf0e --- /dev/null +++ b/packages/processing/src/model-graph.ts @@ -0,0 +1,448 @@ +import type { FeatureCollection } from "geojson"; +import type { + ModelGraphNode, + ModelPortKind, + ModelToolProvider, + ProcessingModelGraph, +} from "@geolibre/core"; +import type { AlgorithmParameter } from "./types"; + +/** + * A value flowing along a model edge. Vector nodes exchange FeatureCollections, + * raster nodes exchange GeoTIFF bytes; carrying the kind with the payload is + * what lets one graph mix Whitebox raster tools with client vector tools and + * still fail fast when an edge would connect two incompatible ports. + */ +export type ModelValue = + | { kind: "vector"; geojson: FeatureCollection } + | { kind: "raster"; bytes: Uint8Array; name?: string }; + +/** One connection point on a {@link ModelToolDescriptor}. */ +export interface ModelToolPort { + /** + * For an input port this is the underlying tool parameter id, so wiring an + * edge and typing a value into the properties panel target the same slot. + */ + id: string; + label: string; + kind: ModelPortKind; + required?: boolean; +} + +/** + * A tool as the Model Builder sees it, independent of which registry produced + * it: ports it can be wired through, plus the parameters the user still has to + * fill in by hand. Adapters build these from the vector algorithm registry and + * from the Whitebox WASM manifests. + */ +export interface ModelToolDescriptor { + /** Globally unique palette key, `":"`. */ + key: string; + provider: ModelToolProvider; + toolId: string; + name: string; + /** Palette grouping label. */ + group: string; + description?: string; + inputs: ModelToolPort[]; + outputs: ModelToolPort[]; + /** Everything not supplied by an edge, rendered in the properties panel. */ + parameters: AlgorithmParameter[]; +} + +/** The single output port every `input` node exposes. */ +export const INPUT_NODE_PORT = "out"; +/** The single input port every `output` node exposes. */ +export const OUTPUT_NODE_PORT = "in"; + +/** Resolve a tool node to its descriptor, or `undefined` when unknown. */ +export type DescriptorResolver = ( + provider: ModelToolProvider | undefined, + toolId: string | undefined, +) => ModelToolDescriptor | undefined; + +/** A problem found in a graph, anchored to the node or edge that carries it. */ +export interface ModelGraphIssue { + /** Machine-readable reason, so the UI can translate rather than show `message`. */ + code: + | "unknown-tool" + | "missing-layer" + | "missing-input" + | "unknown-port" + | "duplicate-input" + | "type-mismatch" + | "cycle" + | "no-output"; + nodeId?: string; + edgeId?: string; + /** English fallback describing the problem. */ + message: string; +} + +/** True when a value of `from` can be fed into a port declaring `to`. */ +export function portKindsCompatible(from: ModelPortKind, to: ModelPortKind): boolean { + return from === "any" || to === "any" || from === to; +} + +/** + * Order nodes so every node follows the ones feeding it (Kahn's algorithm). + * + * @param graph The graph to order. + * @returns Nodes in a runnable order, or `null` when the graph contains a + * cycle — in which case some nodes could never have their inputs ready. + */ +export function topologicalOrder(graph: ProcessingModelGraph): ModelGraphNode[] | null { + const indegree = new Map(); + const outgoing = new Map(); + for (const node of graph.nodes) { + indegree.set(node.id, 0); + outgoing.set(node.id, []); + } + for (const edge of graph.edges) { + if (!indegree.has(edge.from) || !indegree.has(edge.to)) continue; + indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1); + outgoing.get(edge.from)?.push(edge.to); + } + const queue = graph.nodes.filter((node) => (indegree.get(node.id) ?? 0) === 0); + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const ordered: ModelGraphNode[] = []; + while (queue.length > 0) { + const node = queue.shift() as ModelGraphNode; + ordered.push(node); + for (const nextId of outgoing.get(node.id) ?? []) { + const remaining = (indegree.get(nextId) ?? 0) - 1; + indegree.set(nextId, remaining); + if (remaining === 0) { + const next = byId.get(nextId); + if (next) queue.push(next); + } + } + } + return ordered.length === graph.nodes.length ? ordered : null; +} + +/** The ports a node exposes, given its descriptor (tool nodes only). */ +function portsFor( + node: ModelGraphNode, + descriptor: ModelToolDescriptor | undefined, +): { inputs: ModelToolPort[]; outputs: ModelToolPort[] } { + if (node.kind === "input") { + return { inputs: [], outputs: [{ id: INPUT_NODE_PORT, label: "Output", kind: "any" }] }; + } + if (node.kind === "output") { + return { + inputs: [{ id: OUTPUT_NODE_PORT, label: "Input", kind: "any", required: true }], + outputs: [], + }; + } + return { inputs: descriptor?.inputs ?? [], outputs: descriptor?.outputs ?? [] }; +} + +/** + * Check a graph for everything that would make a run fail, so the canvas can + * mark the offending node before the user presses Run. + * + * Covers unknown tools, `input` nodes with no layer chosen, required input + * ports with neither an edge nor a typed parameter value, edges naming a port + * the node does not have, two edges into one port, vector wired into raster (or + * the reverse), cycles, and a graph with no `output` node. + * + * @param graph The graph to check. + * @param resolve Descriptor lookup for tool nodes. + * @returns One issue per problem found; empty when the graph is runnable. + */ +export function validateModelGraph( + graph: ProcessingModelGraph, + resolve: DescriptorResolver, +): ModelGraphIssue[] { + const issues: ModelGraphIssue[] = []; + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const descriptors = new Map(); + for (const node of graph.nodes) { + if (node.kind === "tool") descriptors.set(node.id, resolve(node.provider, node.toolId)); + } + + for (const node of graph.nodes) { + if (node.kind === "input" && !node.layerId) { + issues.push({ code: "missing-layer", nodeId: node.id, message: "Choose an input layer." }); + } + if (node.kind === "tool" && !descriptors.get(node.id)) { + issues.push({ + code: "unknown-tool", + nodeId: node.id, + message: `Unknown tool "${node.toolId ?? ""}"`, + }); + } + } + + // Edges: both endpoints must name a real port, kinds must line up, and no + // input port may take two values. + const filled = new Map>(); + for (const edge of graph.edges) { + const from = byId.get(edge.from); + const to = byId.get(edge.to); + if (!from || !to) continue; + const fromPort = portsFor(from, descriptors.get(from.id)).outputs.find( + (port) => port.id === edge.fromPort, + ); + const toPort = portsFor(to, descriptors.get(to.id)).inputs.find( + (port) => port.id === edge.toPort, + ); + if (!fromPort || !toPort) { + // An unknown tool already reports itself; do not also blame its edges. + if ( + (from.kind !== "tool" || descriptors.get(from.id)) && + (to.kind !== "tool" || descriptors.get(to.id)) + ) { + issues.push({ + code: "unknown-port", + edgeId: edge.id, + message: "Connection refers to a port that no longer exists.", + }); + } + continue; + } + if (!portKindsCompatible(fromPort.kind, toPort.kind)) { + issues.push({ + code: "type-mismatch", + edgeId: edge.id, + message: `Cannot connect ${fromPort.kind} output to ${toPort.kind} input.`, + }); + } + const seen = filled.get(to.id) ?? new Set(); + if (seen.has(toPort.id)) { + issues.push({ + code: "duplicate-input", + edgeId: edge.id, + message: `"${toPort.label}" already has an incoming connection.`, + }); + } + seen.add(toPort.id); + filled.set(to.id, seen); + } + + // Required inputs must be satisfied by an edge or a typed parameter value. + for (const node of graph.nodes) { + const descriptor = descriptors.get(node.id); + if (node.kind === "tool" && !descriptor) continue; + for (const port of portsFor(node, descriptor).inputs) { + if (!port.required) continue; + if (filled.get(node.id)?.has(port.id)) continue; + const typed = node.parameters?.[port.id]; + if (typed !== undefined && typed !== null && typed !== "") continue; + issues.push({ + code: "missing-input", + nodeId: node.id, + message: `"${port.label}" needs a connection or a value.`, + }); + } + } + + if (!topologicalOrder(graph)) { + issues.push({ code: "cycle", message: "The model contains a loop." }); + } + if (!graph.nodes.some((node) => node.kind === "output")) { + issues.push({ code: "no-output", message: "Add an output node to keep a result." }); + } + return issues; +} + +/** Run one tool node: given its resolved inputs, produce a value per output port. */ +export type ModelToolExecutor = (args: { + node: ModelGraphNode; + descriptor: ModelToolDescriptor; + /** Values arriving on the node's input ports, keyed by port id. */ + inputs: Record; + signal?: AbortSignal; +}) => Promise>; + +export interface RunModelGraphOptions { + /** Resolve a tool node's descriptor. */ + resolveDescriptor: DescriptorResolver; + /** Run one tool node. */ + executeTool: ModelToolExecutor; + /** Resolve an `input` node's layer to a value, or `null` when unusable. */ + resolveInput: (layerId: string) => ModelValue | null; + /** Deliver a finished `output` node's value (adds it to the map). */ + emitOutput: (name: string, value: ModelValue, node: ModelGraphNode) => void; + log: (message: string) => void; + signal?: AbortSignal; + /** Called as each node starts and finishes, for canvas progress marking. */ + onNodeStatus?: (nodeId: string, status: "running" | "done" | "error") => void; +} + +/** Outcome of a {@link runModelGraph} call. */ +export interface ModelGraphRunResult { + /** Output-node values produced, keyed by node id. */ + outputs: Record; + /** Set when the run stopped early; names the failing node when there is one. */ + error?: { nodeId?: string; message: string }; +} + +/** + * Execute a validated graph in dependency order. + * + * Each node's inputs are gathered from its incoming edges (falling back, for a + * tool node's unwired input port, to a layer id typed into its parameters and + * resolved through {@link RunModelGraphOptions.resolveInput}). Stops at the + * first node that fails and reports which one, leaving already-produced outputs + * in place so a partial run is still inspectable. + * + * Validate first: this assumes the graph is acyclic and its ports line up. + * + * @param graph The graph to run. + * @param options Resolution, execution and reporting hooks. + * @returns The produced outputs plus the first error, if any. + */ +export async function runModelGraph( + graph: ProcessingModelGraph, + options: RunModelGraphOptions, +): Promise { + const ordered = topologicalOrder(graph); + const outputs: Record = {}; + if (!ordered) { + return { outputs, error: { message: "The model contains a loop." } }; + } + + // Values produced per node, keyed by output port id. + const produced = new Map>(); + const incoming = new Map(); + for (const edge of graph.edges) { + const list = incoming.get(edge.to) ?? []; + list.push(edge); + incoming.set(edge.to, list); + } + + for (const node of ordered) { + if (options.signal?.aborted) { + return { outputs, error: { nodeId: node.id, message: "Run cancelled." } }; + } + + // Gather whatever the upstream nodes put on this node's input ports. + const inputs: Record = {}; + for (const edge of incoming.get(node.id) ?? []) { + const value = produced.get(edge.from)?.[edge.fromPort]; + if (value) inputs[edge.toPort] = value; + } + + try { + if (node.kind === "input") { + options.onNodeStatus?.(node.id, "running"); + const value = node.layerId ? options.resolveInput(node.layerId) : null; + if (!value) { + const message = `Input layer "${node.layerId ?? ""}" has no usable data.`; + options.log(`Error: ${message}`); + options.onNodeStatus?.(node.id, "error"); + return { outputs, error: { nodeId: node.id, message } }; + } + produced.set(node.id, { [INPUT_NODE_PORT]: value }); + options.onNodeStatus?.(node.id, "done"); + continue; + } + + if (node.kind === "output") { + options.onNodeStatus?.(node.id, "running"); + const value = inputs[OUTPUT_NODE_PORT]; + if (!value) { + const message = "Output node has nothing connected to it."; + options.log(`Error: ${message}`); + options.onNodeStatus?.(node.id, "error"); + return { outputs, error: { nodeId: node.id, message } }; + } + const name = node.name?.trim() || "Model output"; + options.emitOutput(name, value, node); + outputs[node.id] = value; + options.onNodeStatus?.(node.id, "done"); + continue; + } + + const descriptor = options.resolveDescriptor(node.provider, node.toolId); + if (!descriptor) { + const message = `Unknown tool "${node.toolId ?? ""}"`; + options.log(`Error: ${message}`); + options.onNodeStatus?.(node.id, "error"); + return { outputs, error: { nodeId: node.id, message } }; + } + + // An unwired input port may still name a project layer typed into the + // properties panel, which is how a single-node model gets its data. + for (const port of descriptor.inputs) { + if (inputs[port.id]) continue; + const typed = node.parameters?.[port.id]; + if (typeof typed !== "string" || !typed) continue; + const value = options.resolveInput(typed); + if (value) inputs[port.id] = value; + } + + options.onNodeStatus?.(node.id, "running"); + options.log(`Running ${descriptor.name}...`); + const result = await options.executeTool({ + node, + descriptor, + inputs, + signal: options.signal, + }); + produced.set(node.id, result); + options.onNodeStatus?.(node.id, "done"); + } catch (err) { + const message = (err as Error).message; + options.log(`Error: ${message}`); + options.onNodeStatus?.(node.id, "error"); + return { outputs, error: { nodeId: node.id, message } }; + } + } + + return { outputs }; +} + +/** + * Project a graph onto the legacy linear {@link ProcessingModelStep} chain, so a + * model authored on the canvas still runs in builds that only understand + * `steps`. + * + * Only an unambiguous chain projects: one input node, one output node, and + * every tool node with exactly one incoming and one outgoing edge. Anything + * with a branch or a multi-input tool returns `[]`, which is the honest answer + * — such a model has no linear equivalent and older builds must not run a + * silently truncated version of it. + * + * @param graph The authored graph. + * @returns The equivalent step chain, or `[]` when there is not one. + */ +export function graphToLinearSteps( + graph: ProcessingModelGraph, +): { id: string; toolId: string; parameters: Record; inputParam?: string }[] { + const ordered = topologicalOrder(graph); + if (!ordered) return []; + const inputs = graph.nodes.filter((node) => node.kind === "input"); + const outs = graph.nodes.filter((node) => node.kind === "output"); + if (inputs.length !== 1 || outs.length !== 1) return []; + + const incoming = new Map(); + const outgoing = new Map(); + for (const edge of graph.edges) { + incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); + outgoing.set(edge.from, (outgoing.get(edge.from) ?? 0) + 1); + } + const steps: { + id: string; + toolId: string; + parameters: Record; + inputParam?: string; + }[] = []; + for (const node of ordered) { + if (node.kind !== "tool") continue; + if ((incoming.get(node.id) ?? 0) !== 1) return []; + if ((outgoing.get(node.id) ?? 0) !== 1) return []; + // Only client vector tools have a `steps` runner to fall back to. + if (node.provider !== "vector" || !node.toolId) return []; + const inputEdge = graph.edges.find((edge) => edge.to === node.id); + steps.push({ + id: node.id, + toolId: node.toolId, + parameters: { ...(node.parameters ?? {}) }, + ...(inputEdge && inputEdge.toPort !== "layer" ? { inputParam: inputEdge.toPort } : {}), + }); + } + return steps; +} diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts new file mode 100644 index 0000000000..99f0653d7a --- /dev/null +++ b/tests/model-graph-edit.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ProcessingModelGraph } from "../packages/core/src/types"; +import type { ModelToolDescriptor } from "../packages/processing/src/model-graph"; +import { + addDataNode, + addToolNode, + autoLayout, + connectNodes, + createsCycle, + emptyModelGraph, + moveNode, + removeEdge, + removeNode, + setNodeField, + setNodeParameter, +} from "../apps/geolibre-desktop/src/lib/model-graph-edit"; + +const BUFFER: ModelToolDescriptor = { + key: "vector:buffer", + provider: "vector", + toolId: "buffer", + name: "Buffer", + group: "Geometry", + inputs: [{ id: "layer", label: "Input", kind: "vector", required: true }], + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters: [ + { id: "distance", label: "Distance", type: "number", default: 25 }, + { id: "units", label: "Units", type: "string" }, + ], +}; + +let counter = 0; +const ids = () => `n${++counter}`; + +describe("adding nodes", () => { + it("seeds a tool node with the descriptor's documented defaults", () => { + counter = 0; + const { graph, nodeId } = addToolNode(emptyModelGraph(), BUFFER, { x: 10, y: 20 }, ids); + const node = graph.nodes.find((entry) => entry.id === nodeId); + // `units` has no default, so it stays unset rather than becoming undefined. + assert.deepEqual(node?.parameters, { distance: 25 }); + assert.equal(node?.provider, "vector"); + assert.equal(node?.toolId, "buffer"); + assert.deepEqual([node?.x, node?.y], [10, 20]); + }); + + it("adds input and output nodes of the right kind", () => { + counter = 0; + const first = addDataNode(emptyModelGraph(), "input", { x: 0, y: 0 }, ids); + const second = addDataNode(first.graph, "output", { x: 0, y: 0 }, ids); + assert.deepEqual( + second.graph.nodes.map((node) => node.kind), + ["input", "output"], + ); + }); +}); + +describe("editing nodes", () => { + const base = (): ProcessingModelGraph => ({ + nodes: [ + { id: "a", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer", parameters: {} }, + { id: "c", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "c", toPort: "in" }, + ], + }); + + it("moves a node without touching the others", () => { + const graph = moveNode(base(), "b", { x: 300, y: 120 }); + assert.deepEqual( + graph.nodes.map((node) => [node.id, node.x, node.y]), + [ + ["a", 0, 0], + ["b", 300, 120], + ["c", 0, 0], + ], + ); + }); + + it("removes a node together with every edge touching it", () => { + const graph = removeNode(base(), "b"); + assert.deepEqual( + graph.nodes.map((node) => node.id), + ["a", "c"], + ); + assert.deepEqual(graph.edges, []); + }); + + it("removes one connection without disturbing the nodes", () => { + const graph = removeEdge(base(), "e1"); + assert.deepEqual( + graph.edges.map((edge) => edge.id), + ["e2"], + ); + assert.equal(graph.nodes.length, 3); + }); + + it("merges a parameter without dropping the others", () => { + let graph = setNodeParameter(base(), "b", "distance", 50); + graph = setNodeParameter(graph, "b", "units", "m"); + assert.deepEqual(graph.nodes.find((node) => node.id === "b")?.parameters, { + distance: 50, + units: "m", + }); + }); + + it("sets the input node's layer and the output node's name", () => { + let graph = setNodeField(base(), "a", "layerId", "rivers"); + graph = setNodeField(graph, "c", "name", "Result"); + assert.equal(graph.nodes.find((node) => node.id === "a")?.layerId, "rivers"); + assert.equal(graph.nodes.find((node) => node.id === "c")?.name, "Result"); + }); +}); + +describe("connecting nodes", () => { + const twoNodes = (): ProcessingModelGraph => ({ + nodes: [ + { id: "a", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "c", kind: "input", x: 0, y: 0, layerId: "rivers" }, + ], + edges: [], + }); + + it("connects an output port to an input port", () => { + counter = 0; + const result = connectNodes( + twoNodes(), + { nodeId: "a", portId: "out" }, + { nodeId: "b", portId: "layer" }, + ids, + ); + assert.ok("graph" in result); + assert.deepEqual( + result.graph.edges.map((edge) => [edge.from, edge.fromPort, edge.to, edge.toPort]), + [["a", "out", "b", "layer"]], + ); + }); + + it("replaces an existing edge into the same port rather than doubling it", () => { + counter = 0; + const first = connectNodes( + twoNodes(), + { nodeId: "a", portId: "out" }, + { nodeId: "b", portId: "layer" }, + ids, + ); + assert.ok("graph" in first); + const second = connectNodes( + first.graph, + { nodeId: "c", portId: "out" }, + { nodeId: "b", portId: "layer" }, + ids, + ); + assert.ok("graph" in second); + // One value per input port: rewiring replaces, it does not accumulate. + assert.equal(second.graph.edges.length, 1); + assert.equal(second.graph.edges[0].from, "c"); + }); + + it("refuses a self-connection", () => { + const result = connectNodes( + twoNodes(), + { nodeId: "b", portId: "out" }, + { nodeId: "b", portId: "layer" }, + ids, + ); + assert.deepEqual(result, { rejected: "same-node" }); + }); + + it("refuses an edge that would close a loop", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + ], + edges: [{ id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }], + }; + const result = connectNodes( + graph, + { nodeId: "b", portId: "out" }, + { nodeId: "a", portId: "layer" }, + ids, + ); + assert.deepEqual(result, { rejected: "cycle" }); + }); + + it("detects a loop across a longer path, not just a direct back-edge", () => { + const graph: ProcessingModelGraph = { + nodes: ["a", "b", "c"].map((id) => ({ + id, + kind: "tool" as const, + x: 0, + y: 0, + provider: "vector" as const, + toolId: "buffer", + })), + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "c", toPort: "layer" }, + ], + }; + // c -> a closes the a -> b -> c chain; a -> c is only a shortcut forward. + assert.equal(createsCycle(graph, "c", "a"), true); + assert.equal(createsCycle(graph, "a", "c"), false); + }); +}); + +describe("auto layout", () => { + it("spreads an unpositioned graph left to right along the flow", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "c", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "c", toPort: "in" }, + ], + }; + const laid = autoLayout(graph); + const x = Object.fromEntries(laid.nodes.map((node) => [node.id, node.x])); + assert.ok(x.a < x.b && x.b < x.c); + }); + + it("leaves a graph that already carries positions alone", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "input", x: 500, y: 300, layerId: "roads" }, + { id: "b", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [], + }; + assert.deepEqual(autoLayout(graph), graph); + }); + + it("stacks siblings of the same depth into separate rows", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "input", x: 0, y: 0, layerId: "one" }, + { id: "b", kind: "input", x: 0, y: 0, layerId: "two" }, + ], + edges: [], + }; + const laid = autoLayout(graph); + assert.equal(laid.nodes[0].x, laid.nodes[1].x); + assert.notEqual(laid.nodes[0].y, laid.nodes[1].y); + }); +}); diff --git a/tests/model-graph.test.ts b/tests/model-graph.test.ts new file mode 100644 index 0000000000..ec5fd30bde --- /dev/null +++ b/tests/model-graph.test.ts @@ -0,0 +1,451 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ProcessingModelGraph } from "../packages/core/src/types"; +import { + graphToLinearSteps, + portKindsCompatible, + runModelGraph, + topologicalOrder, + validateModelGraph, + type ModelToolDescriptor, + type ModelValue, +} from "../packages/processing/src/model-graph"; + +const BUFFER: ModelToolDescriptor = { + key: "vector:buffer", + provider: "vector", + toolId: "buffer", + name: "Buffer", + group: "Geometry", + inputs: [{ id: "layer", label: "Input", kind: "vector", required: true }], + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters: [{ id: "distance", label: "Distance", type: "number" }], +}; + +const CLIP: ModelToolDescriptor = { + key: "vector:clip", + provider: "vector", + toolId: "clip", + name: "Clip", + group: "Overlay", + inputs: [ + { id: "layer", label: "Input", kind: "vector", required: true }, + { id: "overlay", label: "Clip layer", kind: "vector", required: true }, + ], + outputs: [{ id: "out", label: "Output", kind: "vector" }], + parameters: [], +}; + +const SLOPE: ModelToolDescriptor = { + key: "whitebox:slope", + provider: "whitebox", + toolId: "slope", + name: "Slope", + group: "Terrain", + inputs: [{ id: "dem", label: "DEM", kind: "raster", required: true }], + outputs: [{ id: "output", label: "Slope", kind: "raster" }], + parameters: [], +}; + +const TOOLS = [BUFFER, CLIP, SLOPE]; +const resolve = (provider: string | undefined, toolId: string | undefined) => + TOOLS.find((tool) => tool.provider === provider && tool.toolId === toolId); + +function featureCollection(name: string): ModelValue { + return { + kind: "vector", + geojson: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { name }, + geometry: { type: "Point", coordinates: [0, 0] }, + }, + ], + }, + }; +} + +/** input(roads) -> buffer -> output */ +function chainGraph(): ProcessingModelGraph { + return { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "roads" }, + { + id: "t1", + kind: "tool", + x: 100, + y: 0, + provider: "vector", + toolId: "buffer", + parameters: { distance: 50 }, + }, + { id: "out1", kind: "output", x: 200, y: 0, name: "Buffered" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "t1", toPort: "layer" }, + { id: "e2", from: "t1", fromPort: "out", to: "out1", toPort: "in" }, + ], + }; +} + +describe("model graph ordering", () => { + it("orders nodes so each follows the ones feeding it", () => { + const order = topologicalOrder(chainGraph()); + assert.deepEqual( + order?.map((node) => node.id), + ["in1", "t1", "out1"], + ); + }); + + it("returns null for a cycle instead of a partial order", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + ], + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "a", toPort: "layer" }, + ], + }; + assert.equal(topologicalOrder(graph), null); + }); + + it("orders a diamond so a merge node follows both of its branches", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "a" }, + { id: "b1", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "b2", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "clip", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "clip" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "b1", toPort: "layer" }, + { id: "e2", from: "in1", fromPort: "out", to: "b2", toPort: "layer" }, + { id: "e3", from: "b1", fromPort: "out", to: "clip", toPort: "layer" }, + { id: "e4", from: "b2", fromPort: "out", to: "clip", toPort: "overlay" }, + ], + }; + const order = topologicalOrder(graph)?.map((node) => node.id) ?? []; + assert.ok(order.indexOf("clip") > order.indexOf("b1")); + assert.ok(order.indexOf("clip") > order.indexOf("b2")); + }); +}); + +describe("model graph validation", () => { + it("accepts a wired chain", () => { + assert.deepEqual(validateModelGraph(chainGraph(), resolve), []); + }); + + it("reports an input node with no layer chosen", () => { + const graph = chainGraph(); + delete graph.nodes[0].layerId; + const codes = validateModelGraph(graph, resolve).map((issue) => issue.code); + assert.ok(codes.includes("missing-layer")); + }); + + it("reports an unknown tool once, without also blaming its edges", () => { + const graph = chainGraph(); + graph.nodes[1].toolId = "nope"; + const issues = validateModelGraph(graph, resolve); + assert.equal(issues.filter((issue) => issue.code === "unknown-tool").length, 1); + assert.equal(issues.filter((issue) => issue.code === "unknown-port").length, 0); + }); + + it("rejects wiring a vector output into a raster input", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "s", kind: "tool", x: 0, y: 0, provider: "whitebox", toolId: "slope" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "s", toPort: "dem" }, + { id: "e3", from: "s", fromPort: "output", to: "o", toPort: "in" }, + ], + }; + const codes = validateModelGraph(graph, resolve).map((issue) => issue.code); + assert.ok(codes.includes("type-mismatch")); + }); + + it("reports a required input with neither an edge nor a typed value", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "c", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "clip" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [{ id: "e1", from: "c", fromPort: "out", to: "o", toPort: "in" }], + }; + const missing = validateModelGraph(graph, resolve).filter( + (issue) => issue.code === "missing-input", + ); + // Both of Clip's required inputs are unwired. + assert.equal(missing.length, 2); + }); + + it("treats a typed layer value as satisfying a required input", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { + id: "c", + kind: "tool", + x: 0, + y: 0, + provider: "vector", + toolId: "clip", + parameters: { layer: "roads", overlay: "aoi" }, + }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [{ id: "e1", from: "c", fromPort: "out", to: "o", toPort: "in" }], + }; + assert.deepEqual(validateModelGraph(graph, resolve), []); + }); + + it("rejects two edges feeding one input port", () => { + const graph = chainGraph(); + graph.nodes.push({ id: "in2", kind: "input", x: 0, y: 0, layerId: "other" }); + graph.edges.push({ id: "e3", from: "in2", fromPort: "out", to: "t1", toPort: "layer" }); + const codes = validateModelGraph(graph, resolve).map((issue) => issue.code); + assert.ok(codes.includes("duplicate-input")); + }); + + it("requires an output node so a run keeps something", () => { + const graph = chainGraph(); + graph.nodes = graph.nodes.filter((node) => node.kind !== "output"); + graph.edges = graph.edges.filter((edge) => edge.to !== "out1"); + const codes = validateModelGraph(graph, resolve).map((issue) => issue.code); + assert.ok(codes.includes("no-output")); + }); +}); + +describe("port compatibility", () => { + it("lets `any` bridge both concrete kinds but keeps those two apart", () => { + assert.equal(portKindsCompatible("vector", "vector"), true); + assert.equal(portKindsCompatible("any", "raster"), true); + assert.equal(portKindsCompatible("raster", "any"), true); + assert.equal(portKindsCompatible("vector", "raster"), false); + }); +}); + +describe("running a model graph", () => { + const baseOptions = () => { + const log: string[] = []; + const emitted: { name: string; value: ModelValue }[] = []; + return { + log, + emitted, + options: { + resolveDescriptor: resolve, + resolveInput: (layerId: string) => featureCollection(layerId), + emitOutput: (name: string, value: ModelValue) => emitted.push({ name, value }), + log: (message: string) => log.push(message), + }, + }; + }; + + it("feeds an input layer through a tool into an output", async () => { + const { options, emitted } = baseOptions(); + const seen: Record[] = []; + const result = await runModelGraph(chainGraph(), { + ...options, + executeTool: async ({ inputs }) => { + seen.push(inputs); + return { out: featureCollection("buffered") }; + }, + }); + assert.equal(result.error, undefined); + assert.equal(emitted.length, 1); + assert.equal(emitted[0].name, "Buffered"); + // The tool saw the input node's layer on its `layer` port. + assert.equal(seen[0].layer.kind, "vector"); + }); + + it("delivers both branches of a merge to the right ports", async () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "in2", kind: "input", x: 0, y: 0, layerId: "aoi" }, + { id: "c", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "clip" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Clipped" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "c", toPort: "layer" }, + { id: "e2", from: "in2", fromPort: "out", to: "c", toPort: "overlay" }, + { id: "e3", from: "c", fromPort: "out", to: "o", toPort: "in" }, + ], + }; + const { options } = baseOptions(); + let ports: Record = {}; + const result = await runModelGraph(graph, { + ...options, + executeTool: async ({ inputs }) => { + ports = inputs; + return { out: featureCollection("clipped") }; + }, + }); + assert.equal(result.error, undefined); + assert.deepEqual(Object.keys(ports).sort(), ["layer", "overlay"]); + assert.equal( + (ports.layer as { geojson: { features: { properties: { name: string } }[] } }).geojson + .features[0].properties.name, + "roads", + ); + assert.equal( + (ports.overlay as { geojson: { features: { properties: { name: string } }[] } }).geojson + .features[0].properties.name, + "aoi", + ); + }); + + it("stops at the failing node and names it", async () => { + const { options, emitted } = baseOptions(); + const result = await runModelGraph(chainGraph(), { + ...options, + executeTool: async () => { + throw new Error("tool exploded"); + }, + }); + assert.equal(result.error?.nodeId, "t1"); + assert.match(result.error?.message ?? "", /tool exploded/); + assert.equal(emitted.length, 0); + }); + + it("reports the node when an input layer has no usable data", async () => { + const { options } = baseOptions(); + const result = await runModelGraph(chainGraph(), { + ...options, + resolveInput: () => null, + executeTool: async () => ({ out: featureCollection("x") }), + }); + assert.equal(result.error?.nodeId, "in1"); + }); + + it("carries raster bytes between two raster nodes", async () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "dem" }, + { id: "s", kind: "tool", x: 0, y: 0, provider: "whitebox", toolId: "slope" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Slope" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "s", toPort: "dem" }, + { id: "e2", from: "s", fromPort: "output", to: "o", toPort: "in" }, + ], + }; + const { options, emitted } = baseOptions(); + const result = await runModelGraph(graph, { + ...options, + resolveInput: () => ({ kind: "raster", bytes: new Uint8Array([1, 2, 3]), name: "dem" }), + executeTool: async ({ inputs }) => { + assert.equal(inputs.dem.kind, "raster"); + return { output: { kind: "raster", bytes: new Uint8Array([9]), name: "slope" } }; + }, + }); + assert.equal(result.error, undefined); + assert.equal(emitted[0].value.kind, "raster"); + }); + + it("resolves an unwired input port from a layer id typed into the node", async () => { + const graph: ProcessingModelGraph = { + nodes: [ + { + id: "t", + kind: "tool", + x: 0, + y: 0, + provider: "vector", + toolId: "buffer", + parameters: { layer: "roads", distance: 10 }, + }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [{ id: "e1", from: "t", fromPort: "out", to: "o", toPort: "in" }], + }; + const { options } = baseOptions(); + let saw: Record = {}; + const result = await runModelGraph(graph, { + ...options, + executeTool: async ({ inputs }) => { + saw = inputs; + return { out: featureCollection("b") }; + }, + }); + assert.equal(result.error, undefined); + assert.equal(saw.layer?.kind, "vector"); + }); + + it("does not start a node once the signal is aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const { options } = baseOptions(); + let ran = false; + const result = await runModelGraph(chainGraph(), { + ...options, + signal: controller.signal, + executeTool: async () => { + ran = true; + return { out: featureCollection("x") }; + }, + }); + assert.equal(ran, false); + assert.match(result.error?.message ?? "", /cancelled/i); + }); +}); + +describe("legacy linear projection", () => { + it("projects a single chain so older builds can still run it", () => { + const steps = graphToLinearSteps(chainGraph()); + assert.deepEqual( + steps.map((step) => step.toolId), + ["buffer"], + ); + assert.deepEqual(steps[0].parameters, { distance: 50 }); + }); + + it("refuses to project a multi-input tool rather than truncating it", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "in2", kind: "input", x: 0, y: 0, layerId: "aoi" }, + { id: "c", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "clip" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "c", toPort: "layer" }, + { id: "e2", from: "in2", fromPort: "out", to: "c", toPort: "overlay" }, + { id: "e3", from: "c", fromPort: "out", to: "o", toPort: "in" }, + ], + }; + assert.deepEqual(graphToLinearSteps(graph), []); + }); + + it("refuses to project a graph containing a Whitebox node", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "dem" }, + { id: "s", kind: "tool", x: 0, y: 0, provider: "whitebox", toolId: "slope" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "s", toPort: "dem" }, + { id: "e2", from: "s", fromPort: "output", to: "o", toPort: "in" }, + ], + }; + assert.deepEqual(graphToLinearSteps(graph), []); + }); + + it("records a non-default input port so the chain rewires correctly", () => { + const graph = chainGraph(); + graph.nodes[1].provider = "vector"; + graph.edges[0].toPort = "input"; + // Buffer's descriptor names its port `layer`; an edge onto `input` is what a + // tool with a differently-named primary input would produce. + const steps = graphToLinearSteps(graph); + assert.equal(steps[0].inputParam, "input"); + }); +}); diff --git a/tests/model-tool-catalog.test.ts b/tests/model-tool-catalog.test.ts new file mode 100644 index 0000000000..91762af90a --- /dev/null +++ b/tests/model-tool-catalog.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ProcessingAlgorithm, WhiteboxTool } from "../packages/processing/src"; +import { + buildModelToolCatalog, + groupModelTools, + modelToolKey, + searchModelTools, + vectorToolDescriptor, + whiteboxToolDescriptor, +} from "../apps/geolibre-desktop/src/lib/model-tool-catalog"; + +const bufferAlgorithm: ProcessingAlgorithm = { + id: "buffer", + name: "Buffer", + description: "Buffer features", + group: "Geometry", + parameters: [ + { id: "layer", label: "Input layer", type: "layer", required: true }, + { id: "distance", label: "Distance", type: "number", default: 10 }, + ], + run: () => {}, +}; + +const clipAlgorithm: ProcessingAlgorithm = { + id: "clip", + name: "Clip", + description: "Clip by another layer", + group: "Overlay", + parameters: [ + { id: "layer", label: "Input layer", type: "layer", required: true }, + { id: "overlay", label: "Clip layer", type: "layer", required: true }, + ], + run: () => {}, +}; + +const slopeTool: WhiteboxTool = { + id: "slope", + display_name: "Slope", + summary: "Surface slope from a DEM", + taxonomy_category: "Terrain Analysis", + params: [ + { name: "dem", kind: "raster_in", required: true }, + { name: "output", kind: "raster_out" }, + { name: "zfactor", kind: "double", default: 1 }, + { name: "units", kind: "enum", options: ["degrees", "radians"] }, + ], +}; + +describe("vector tool descriptors", () => { + it("turns layer parameters into typed input ports", () => { + const descriptor = vectorToolDescriptor(clipAlgorithm); + assert.deepEqual( + descriptor.inputs.map((port) => [port.id, port.kind, port.required]), + [ + ["layer", "vector", true], + ["overlay", "vector", true], + ], + ); + }); + + it("gives every vector tool one vector output port", () => { + const descriptor = vectorToolDescriptor(bufferAlgorithm); + assert.deepEqual(descriptor.outputs, [{ id: "out", label: "Output", kind: "vector" }]); + }); + + it("keeps layer parameters in the properties panel as well as on ports", () => { + // A single-node model names its layer by hand rather than drawing an input + // node, so the field has to stay available. + const descriptor = vectorToolDescriptor(bufferAlgorithm); + assert.ok(descriptor.parameters.some((param) => param.id === "layer")); + assert.ok(descriptor.parameters.some((param) => param.id === "distance")); + }); + + it("namespaces the key by provider so both registries can define `buffer`", () => { + assert.equal(vectorToolDescriptor(bufferAlgorithm).key, "vector:buffer"); + assert.equal(modelToolKey("whitebox", "buffer"), "whitebox:buffer"); + }); +}); + +describe("whitebox tool descriptors", () => { + it("maps dataset parameters to ports and scalars to fields", () => { + const descriptor = whiteboxToolDescriptor(slopeTool); + assert.ok(descriptor); + assert.deepEqual( + descriptor.inputs.map((port) => [port.id, port.kind]), + [["dem", "raster"]], + ); + assert.deepEqual( + descriptor.outputs.map((port) => [port.id, port.kind]), + [["output", "raster"]], + ); + assert.deepEqual( + descriptor.parameters.map((param) => [param.id, param.type]), + [ + ["zfactor", "number"], + ["units", "select"], + ], + ); + }); + + it("carries enum choices through as select options", () => { + const descriptor = whiteboxToolDescriptor(slopeTool); + const units = descriptor?.parameters.find((param) => param.id === "units"); + assert.deepEqual(units?.options, [ + { value: "degrees", label: "degrees" }, + { value: "radians", label: "radians" }, + ]); + }); + + it("classifies a vector-in/vector-out tool as vector ports", () => { + const descriptor = whiteboxToolDescriptor({ + id: "buffer_vector", + display_name: "Buffer Vector", + params: [ + { name: "input", kind: "vector_in", required: true }, + { name: "output", kind: "vector_out" }, + { name: "distance", kind: "double" }, + ], + }); + assert.equal(descriptor?.inputs[0].kind, "vector"); + assert.equal(descriptor?.outputs[0].kind, "vector"); + }); + + it("drops a tool with no output port rather than stranding it on the canvas", () => { + const descriptor = whiteboxToolDescriptor({ + id: "print_stats", + params: [{ name: "input", kind: "raster_in", required: true }], + }); + assert.equal(descriptor, null); + }); + + it("keeps a LiDAR input as a field, since no edge can carry one", () => { + const descriptor = whiteboxToolDescriptor({ + id: "lidar_thing", + params: [ + { name: "cloud", kind: "lidar_in", required: true }, + { name: "output", kind: "raster_out" }, + ], + }); + assert.deepEqual( + descriptor?.inputs.map((port) => port.id), + [], + ); + assert.equal(descriptor?.parameters.find((param) => param.id === "cloud")?.type, "path"); + }); +}); + +describe("the combined palette", () => { + it("includes both registries and sorts by group then name", () => { + const catalog = buildModelToolCatalog([bufferAlgorithm, clipAlgorithm], [slopeTool]); + assert.deepEqual( + catalog.map((descriptor) => descriptor.key), + ["vector:buffer", "vector:clip", "whitebox:slope"], + ); + }); + + it("omits locked pro-tier tools, which could never run", () => { + const catalog = buildModelToolCatalog( + [], + [slopeTool, { ...slopeTool, id: "locked_tool", locked: true }], + ); + assert.deepEqual( + catalog.map((descriptor) => descriptor.toolId), + ["slope"], + ); + }); + + it("groups entries in their sorted order", () => { + const groups = groupModelTools( + buildModelToolCatalog([bufferAlgorithm, clipAlgorithm], [slopeTool]), + ); + assert.deepEqual( + groups.map((entry) => entry.group), + ["Geometry", "Overlay", "Terrain Analysis"], + ); + }); + + it("searches across name, id and group", () => { + const catalog = buildModelToolCatalog([bufferAlgorithm, clipAlgorithm], [slopeTool]); + assert.deepEqual( + searchModelTools(catalog, "terrain").map((descriptor) => descriptor.toolId), + ["slope"], + ); + assert.deepEqual( + searchModelTools(catalog, "clip").map((descriptor) => descriptor.toolId), + ["clip"], + ); + assert.equal(searchModelTools(catalog, " ").length, catalog.length); + }); +}); From b32e8c1c6e775f6333991452278a3abc8ca015de Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 17 Aug 2026 22:38:21 -0400 Subject: [PATCH 11/22] fix(processing): make Whitebox raster nodes actually runnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing the raster path against a real DEM (2880x1773, Oregon Cascades) turned up two defects that no unit test would have caught. The WASM runner builds its CLI arguments by walking `request.tool.params`, and the executor never passed `tool` — so every Whitebox node ran with no arguments at all and the binary rejected it with "missing required parameter 'input_dem'". ModelToolDescriptor now carries the provider's own tool record as `native` and the executor hands it back to the runner. `resolveInput` was synchronous and only understood `layer.geojson`, so an input node pointing at a raster resolved to null and the run stopped before the first tool. It is now async and fetches a raster layer's bytes through the same `fetchLayerBytes` path the Whitebox toolbox uses, so a locally loaded GeoTIFF resolves via its blob URL rather than an unreadable path. Verified end to end in the browser: Input(dem.tif) -> Fill Depressions -> D8 Pointer -> Output ran both tools in sequence, each writing a GeoTIFF converted to a COG, and added the D8 pointer raster to the map. Running the same two steps offline confirms the semantics: Fill Depressions raises 59,455 cells (4.07%) by at most 14.789 m and is monotone over the input, and the D8 pointer grid holds exactly {0,1,2,4,8,16,32,64,128} — every non-zero value a power of two, as the D8 encoding requires. --- .../model-builder/ModelBuilderPanel.tsx | 28 +++++++++++++++++-- .../src/lib/model-tool-catalog.ts | 2 ++ packages/processing/src/model-graph.ts | 19 ++++++++++--- tests/model-graph.test.ts | 18 ++++++++++++ tests/model-tool-catalog.test.ts | 8 ++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index df4e7ab073..8969700680 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -22,6 +22,7 @@ import { type ModelToolDescriptor, type ModelValue, type WhiteboxLayerInput, + type WhiteboxTool, } from "@geolibre/processing"; import { Button, Input, Label, ScrollArea, Select, cn } from "@geolibre/ui"; import { Download, GripVertical, Loader2, Play, Plus, Save, Trash2, Upload, X } from "lucide-react"; @@ -59,6 +60,7 @@ import { setNodeField, setNodeParameter, } from "../../../lib/model-graph-edit"; +import { fetchLayerBytes } from "../../../lib/whitebox-layer-inputs"; import { ParameterField } from "../ParameterField"; /** MIME type carrying a palette tool key through an HTML5 drag. */ @@ -1185,11 +1187,29 @@ function stepsToGraph(model: ProcessingModel): ProcessingModelGraph { return { nodes, edges }; } -/** Wrap a project layer as a model value the graph runner can carry. */ -function layerToModelValue(layers: GeoLibreLayer[], layerId: string): ModelValue | null { +/** + * Wrap a project layer as a model value the graph runner can carry. + * + * A vector layer hands over its in-memory GeoJSON directly. A raster layer has + * to have its bytes fetched — the same path the Whitebox toolbox uses for a + * `raster_in`, so a locally loaded GeoTIFF resolves through its blob URL rather + * than a file path the browser cannot read. + * + * @param layers The project layers. + * @param layerId The layer an input node points at. + * @returns The value, or `null` when the layer holds nothing runnable. + */ +async function layerToModelValue( + layers: GeoLibreLayer[], + layerId: string, +): Promise { const layer = layers.find((entry) => entry.id === layerId); if (!layer) return null; if (layer.geojson) return { kind: "vector", geojson: layer.geojson }; + if (["raster", "cog", "wms", "wmts", "xyz", "zarr"].includes(layer.type)) { + const bytes = await fetchLayerBytes(layer); + if (bytes) return { kind: "raster", bytes, name: layer.name }; + } return null; } @@ -1254,6 +1274,10 @@ async function executeModelTool({ const job = await runWhiteboxToolWasm({ tool_id: descriptor.toolId, parameters: { ...(node.parameters ?? {}) }, + // The WASM runner builds its CLI arguments by walking `tool.params`; without + // the manifest it passes none and the binary rejects the run as missing a + // required parameter. + tool: descriptor.native as WhiteboxTool | undefined, layer_inputs: layerInputs, include_pro: false, tier: "open", diff --git a/apps/geolibre-desktop/src/lib/model-tool-catalog.ts b/apps/geolibre-desktop/src/lib/model-tool-catalog.ts index 39865e0845..1ad64b67af 100644 --- a/apps/geolibre-desktop/src/lib/model-tool-catalog.ts +++ b/apps/geolibre-desktop/src/lib/model-tool-catalog.ts @@ -147,6 +147,8 @@ export function whiteboxToolDescriptor(tool: WhiteboxTool): ModelToolDescriptor inputs, outputs, parameters, + // The WASM runner walks this manifest to build its CLI arguments. + native: tool, }; } diff --git a/packages/processing/src/model-graph.ts b/packages/processing/src/model-graph.ts index 9003bebf0e..09e856763b 100644 --- a/packages/processing/src/model-graph.ts +++ b/packages/processing/src/model-graph.ts @@ -48,6 +48,14 @@ export interface ModelToolDescriptor { outputs: ModelToolPort[]; /** Everything not supplied by an edge, rendered in the properties panel. */ parameters: AlgorithmParameter[]; + /** + * The provider's own tool record, carried through verbatim so the executor + * can hand it back to that provider's runner. The Whitebox WASM runner builds + * its CLI arguments by walking this manifest's params, so a node that loses it + * runs with no arguments at all and the binary rejects it as missing a + * required parameter. Opaque here to keep the graph engine provider-agnostic. + */ + native?: unknown; } /** The single output port every `input` node exposes. */ @@ -261,8 +269,11 @@ export interface RunModelGraphOptions { resolveDescriptor: DescriptorResolver; /** Run one tool node. */ executeTool: ModelToolExecutor; - /** Resolve an `input` node's layer to a value, or `null` when unusable. */ - resolveInput: (layerId: string) => ModelValue | null; + /** + * Resolve an `input` node's layer to a value, or `null` when unusable. + * Async because a raster layer's bytes have to be fetched. + */ + resolveInput: (layerId: string) => Promise | ModelValue | null; /** Deliver a finished `output` node's value (adds it to the map). */ emitOutput: (name: string, value: ModelValue, node: ModelGraphNode) => void; log: (message: string) => void; @@ -328,7 +339,7 @@ export async function runModelGraph( try { if (node.kind === "input") { options.onNodeStatus?.(node.id, "running"); - const value = node.layerId ? options.resolveInput(node.layerId) : null; + const value = node.layerId ? await options.resolveInput(node.layerId) : null; if (!value) { const message = `Input layer "${node.layerId ?? ""}" has no usable data.`; options.log(`Error: ${message}`); @@ -370,7 +381,7 @@ export async function runModelGraph( if (inputs[port.id]) continue; const typed = node.parameters?.[port.id]; if (typeof typed !== "string" || !typed) continue; - const value = options.resolveInput(typed); + const value = await options.resolveInput(typed); if (value) inputs[port.id] = value; } diff --git a/tests/model-graph.test.ts b/tests/model-graph.test.ts index ec5fd30bde..6211511c18 100644 --- a/tests/model-graph.test.ts +++ b/tests/model-graph.test.ts @@ -350,6 +350,24 @@ describe("running a model graph", () => { assert.equal(emitted[0].value.kind, "raster"); }); + it("awaits an async input resolver, since raster bytes have to be fetched", async () => { + const { options, emitted } = baseOptions(); + const result = await runModelGraph(chainGraph(), { + ...options, + resolveInput: async (layerId: string) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return { kind: "raster", bytes: new Uint8Array([1, 2]), name: layerId }; + }, + executeTool: async ({ inputs }) => { + // A resolver that was not awaited would deliver a Promise here. + assert.equal(inputs.layer.kind, "raster"); + return { out: { kind: "raster", bytes: new Uint8Array([3]), name: "o" } }; + }, + }); + assert.equal(result.error, undefined); + assert.equal(emitted[0].value.kind, "raster"); + }); + it("resolves an unwired input port from a layer id typed into the node", async () => { const graph: ProcessingModelGraph = { nodes: [ diff --git a/tests/model-tool-catalog.test.ts b/tests/model-tool-catalog.test.ts index 91762af90a..88514e96f3 100644 --- a/tests/model-tool-catalog.test.ts +++ b/tests/model-tool-catalog.test.ts @@ -122,6 +122,14 @@ describe("whitebox tool descriptors", () => { assert.equal(descriptor?.outputs[0].kind, "vector"); }); + it("carries the manifest through as `native` for the WASM runner", () => { + // The runner builds its CLI arguments by walking `tool.params`; a descriptor + // that drops the manifest makes every Whitebox node run with no arguments + // and the binary rejects it as missing a required parameter. + const descriptor = whiteboxToolDescriptor(slopeTool); + assert.equal(descriptor?.native, slopeTool); + }); + it("drops a tool with no output port rather than stranding it on the canvas", () => { const descriptor = whiteboxToolDescriptor({ id: "print_stats", From 8806ec992b341b168a8554025d7949ec98368b83 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 17 Aug 2026 23:04:19 -0400 Subject: [PATCH 12/22] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate an imported model through core's normalizeModelGraph instead of an `Array.isArray(nodes)` spot check. A file with `nodes` but no `edges` key passed the old check and reached the canvas with `edges: undefined`, which threw out of GraphEdges' render — past the importer's try/catch — into the panel's error boundary rather than showing importInvalid. Exports now carry $schema/version so a stray JSON is rejected by name. - Delete lib/processing-pipeline.ts and its test. The split left it with no caller, so the repo carried two incompatible model-interchange formats and only the graph one was reachable. Removing it leaves the versioned .model.json as the single format. - Drop the imports BatchToolsDialog stopped using when the Models tab left. - Report duplicate node ids and dangling edges from validateModelGraph instead of silently collapsing or skipping them, and stop counting dangling edges in graphToLinearSteps, where they could make a node look like it had the single predecessor that projection requires. - Drain the topological queue with an index cursor; shift() is O(remaining) per call, and this recomputes on every graph edit. - Report a non-Error rejection from executeTool instead of reading `.message` off it, which threw inside the handler and escaped as an unhandled rejection. - Settle a dragged node on drop so it never comes to rest covering another card, and repaint it last. Overlapping cards swallow the hit-test for the ports underneath, which left them unclickable with no way back. - Add a Cancel button and abandon an in-flight run on close, New, Import and Load. The abort controller was stored but never fired, so a stuck WASM job could not be stopped, and a superseded run kept writing log lines, node highlighting and result layers into the session that replaced it. --- .../processing/BatchToolsDialog.tsx | 24 +-- .../model-builder/ModelBuilderPanel.tsx | 125 ++++++++++--- .../geolibre-desktop/src/i18n/locales/ar.json | 3 + .../geolibre-desktop/src/i18n/locales/de.json | 3 + .../geolibre-desktop/src/i18n/locales/en.json | 3 + .../geolibre-desktop/src/i18n/locales/es.json | 3 + .../geolibre-desktop/src/i18n/locales/fa.json | 3 + .../geolibre-desktop/src/i18n/locales/fr.json | 3 + .../geolibre-desktop/src/i18n/locales/hi.json | 3 + .../geolibre-desktop/src/i18n/locales/id.json | 3 + .../geolibre-desktop/src/i18n/locales/it.json | 3 + .../geolibre-desktop/src/i18n/locales/ja.json | 3 + .../geolibre-desktop/src/i18n/locales/ka.json | 3 + .../geolibre-desktop/src/i18n/locales/ko.json | 3 + .../geolibre-desktop/src/i18n/locales/nl.json | 3 + .../geolibre-desktop/src/i18n/locales/pt.json | 3 + .../geolibre-desktop/src/i18n/locales/ru.json | 3 + .../geolibre-desktop/src/i18n/locales/th.json | 3 + .../geolibre-desktop/src/i18n/locales/tr.json | 3 + .../geolibre-desktop/src/i18n/locales/vi.json | 3 + .../geolibre-desktop/src/i18n/locales/zh.json | 3 + .../src/lib/model-graph-edit.ts | 26 ++- .../src/lib/processing-pipeline.ts | 119 ------------ packages/processing/src/model-graph.ts | 48 ++++- tests/core-project.test.ts | 61 ++++++ tests/model-graph-edit.test.ts | 43 +++++ tests/model-graph.test.ts | 42 +++++ tests/processing-pipeline.test.ts | 177 ------------------ 28 files changed, 366 insertions(+), 356 deletions(-) delete mode 100644 apps/geolibre-desktop/src/lib/processing-pipeline.ts delete mode 100644 tests/processing-pipeline.test.ts diff --git a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx index 3754b2f768..bfd3671e90 100644 --- a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx @@ -1,23 +1,16 @@ import { useTranslation } from "react-i18next"; -import { - useAppStore, - type GeoLibreLayer, - type ProcessingModel, - type ProcessingModelStep, -} from "@geolibre/core"; +import { useAppStore, type GeoLibreLayer } from "@geolibre/core"; import { detectGeometryProfile, type MapController } from "@geolibre/map"; import { VECTOR_TOOLS, getVectorTool, runAlgorithmCapture, - runModel, type AlgorithmParameter, type GeometryFamily, type ProcessingAlgorithm, type RunnerHost, } from "@geolibre/processing"; import { createDuckDbCapability } from "../../lib/duckdb-processing"; -import { modelToPipeline, pipelineToModel } from "../../lib/processing-pipeline"; import { Button, Dialog, @@ -29,23 +22,10 @@ import { Label, ScrollArea, Select, - Separator, cn, } from "@geolibre/ui"; import { ParameterField } from "./ParameterField"; -import { - ArrowDown, - ArrowUp, - Download, - Layers, - Loader2, - Play, - Plus, - Save, - Trash2, - Upload, - Workflow, -} from "lucide-react"; +import { Download, Layers, Loader2, Play, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; interface BatchToolsDialogProps { diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 8969700680..e0d95eb4bc 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -1,5 +1,6 @@ import { DEFAULT_LAYER_STYLE, + normalizeModelGraph, useAppStore, type GeoLibreLayer, type ModelGraphNode, @@ -59,6 +60,7 @@ import { removeNode, setNodeField, setNodeParameter, + settleNode, } from "../../../lib/model-graph-edit"; import { fetchLayerBytes } from "../../../lib/whitebox-layer-inputs"; import { ParameterField } from "../ParameterField"; @@ -66,6 +68,10 @@ import { ParameterField } from "../ParameterField"; /** MIME type carrying a palette tool key through an HTML5 drag. */ const TOOL_DRAG_TYPE = "application/x-geolibre-model-tool"; +/** Identifies an exported Model Builder file, so a stray JSON is rejected. */ +const MODEL_SCHEMA = "https://geolibre.app/schemas/model-graph-v1.json"; +const MODEL_VERSION = "1.0.0"; + const MIN_WIDTH = 820; const MIN_HEIGHT = 420; const EDGE_MARGIN = 12; @@ -219,10 +225,21 @@ export function ModelBuilderPanel({ const filtered = useMemo(() => searchModelTools(catalog, search), [catalog, search]); const groups = useMemo(() => groupModelTools(filtered), [filtered]); + /** + * Abandon any run still in flight. Its closure holds the graph and layers of + * the session being replaced, so without this its log lines, node highlighting + * and result layers would bleed into whatever the user switched to. + */ + const abortRun = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + }, []); + const resetRunState = useCallback(() => { + abortRun(); setNodeStatus({}); setLog([]); - }, []); + }, [abortRun]); const handleNewModel = useCallback(() => { setModelId(createId()); @@ -256,7 +273,11 @@ export function ModelBuilderPanel({ }, [saveModel, modelId, modelName, graph, appendLog, t]); const handleExport = useCallback(() => { - const json = JSON.stringify({ name: modelName, graph }, null, 2); + const json = JSON.stringify( + { $schema: MODEL_SCHEMA, version: MODEL_VERSION, name: modelName, graph }, + null, + 2, + ); const url = URL.createObjectURL(new Blob([json], { type: "application/json" })); const anchor = document.createElement("a"); const slug = modelName @@ -279,17 +300,26 @@ export function ModelBuilderPanel({ async (file: File) => { try { const parsed = JSON.parse(await file.text()) as { + $schema?: unknown; + version?: unknown; name?: unknown; - graph?: ProcessingModelGraph; + graph?: unknown; }; - if (!parsed.graph || !Array.isArray(parsed.graph.nodes)) { - throw new Error(t("processing.modelBuilder.importInvalid")); + if (parsed.$schema !== undefined && parsed.$schema !== MODEL_SCHEMA) { + throw new Error(t("processing.modelBuilder.importUnsupported")); } + // normalizeModelGraph is the same coercion the project loader applies: + // it drops nodes without a usable id or kind and edges that do not + // connect two surviving nodes, so a hand-edited file cannot reach the + // canvas with (say) a missing `edges` array that would then throw out + // of render, past this try/catch, into the panel's error boundary. + const graph = normalizeModelGraph(parsed.graph); + if (!graph) throw new Error(t("processing.modelBuilder.importInvalid")); setModelName(typeof parsed.name === "string" ? parsed.name : ""); - setGraph(autoLayout(parsed.graph)); + setGraph(autoLayout(graph)); setSelectedNodeId(null); resetRunState(); - appendLog(t("processing.modelBuilder.importedLog", { nodes: parsed.graph.nodes.length })); + appendLog(t("processing.modelBuilder.importedLog", { nodes: graph.nodes.length })); } catch (err) { appendLog(`${t("processing.modelBuilder.importFailed")}: ${(err as Error).message}`); } @@ -372,6 +402,9 @@ export function ModelBuilderPanel({ handle.removeEventListener("pointermove", handleMove); handle.removeEventListener("pointerup", handleEnd); handle.removeEventListener("pointercancel", handleEnd); + // Settle on drop so a card never comes to rest covering another's + // ports, which would make those ports unclickable with no way back. + setGraph((current) => settleNode(current, node.id)); }; handle.addEventListener("pointermove", handleMove); handle.addEventListener("pointerup", handleEnd); @@ -457,6 +490,8 @@ export function ModelBuilderPanel({ resolveDescriptor, resolveInput: (layerId) => layerToModelValue(layers, layerId), emitOutput: (name, value) => { + // A cancelled run must not drop layers into the session that replaced it. + if (controller.signal.aborted) return; if (value.kind === "vector") { addGeoJsonLayer(name, value.geojson); } else if (onAddRaster) { @@ -465,10 +500,14 @@ export function ModelBuilderPanel({ appendLog(t("processing.modelBuilder.rasterOutputUnsupported", { name })); } }, - log: appendLog, + log: (message) => { + if (!controller.signal.aborted) appendLog(message); + }, signal: controller.signal, - onNodeStatus: (nodeId, status) => - setNodeStatus((current) => ({ ...current, [nodeId]: status })), + onNodeStatus: (nodeId, status) => { + if (controller.signal.aborted) return; + setNodeStatus((current) => ({ ...current, [nodeId]: status })); + }, executeTool: async ({ node, descriptor, inputs, signal }) => executeModelTool({ node, @@ -480,16 +519,22 @@ export function ModelBuilderPanel({ log: appendLog, }), }); - appendLog( - result.error - ? `${t("processing.modelBuilder.runFailed")}: ${result.error.message}` - : t("processing.modelBuilder.runFinished", { - outputs: Object.keys(result.outputs).length, - }), - ); + if (!controller.signal.aborted) { + appendLog( + result.error + ? `${t("processing.modelBuilder.runFailed")}: ${result.error.message}` + : t("processing.modelBuilder.runFinished", { + outputs: Object.keys(result.outputs).length, + }), + ); + } } finally { - setRunning(false); - abortRef.current = null; + // Only the run that still owns the controller clears the busy state; a + // superseded run must not stop the spinner for the one that replaced it. + if (abortRef.current === controller) { + setRunning(false); + abortRef.current = null; + } } }, [issues.length, graph, resolveDescriptor, layers, addGeoJsonLayer, onAddRaster, appendLog, t]); @@ -618,24 +663,42 @@ export function ModelBuilderPanel({ - + ) : ( + + {t("processing.modelBuilder.runModel")} + + )} ))}
)) @@ -886,9 +936,14 @@ export function ModelBuilderPanel({ {/* Issues + log */}
+ {catalogFailed && ( +
+ {t("processing.modelBuilder.catalogUnavailable")} +
+ )} {issues.map((issue, index) => (
- {issue.message} + {translateIssue(t, issue)}
))} {log.map((line, index) => ( @@ -896,7 +951,7 @@ export function ModelBuilderPanel({ {line}
))} - {issues.length === 0 && log.length === 0 && ( + {issues.length === 0 && log.length === 0 && !catalogFailed && ( {t("processing.modelBuilder.outputPlaceholder")} @@ -915,6 +970,49 @@ export function ModelBuilderPanel({ ); } +/** + * Resolve a validation issue to the user's language. + * + * `ModelGraphIssue` carries a machine-readable `code` precisely so the UI can + * translate rather than print the engine's English `message`; rendering the + * message verbatim left every validation problem English-only in all 19 + * locales. Port names and tool ids inside a message are data, so they are + * interpolated rather than translated. + */ +function translateIssue(t: TFunction, issue: ModelGraphIssue): string { + switch (issue.code) { + case "missing-layer": + return t("processing.modelBuilder.issueMissingLayer"); + case "unknown-tool": + return t("processing.modelBuilder.issueUnknownTool", { tool: issue.detail ?? "" }); + case "missing-input": + return t("processing.modelBuilder.issueMissingInput", { port: issue.detail ?? "" }); + case "unknown-port": + return t("processing.modelBuilder.issueUnknownPort"); + case "duplicate-input": + return t("processing.modelBuilder.issueDuplicateInput", { port: issue.detail ?? "" }); + case "type-mismatch": + return t("processing.modelBuilder.issueTypeMismatch"); + case "cycle": + return t("processing.modelBuilder.issueCycle"); + case "no-output": + return t("processing.modelBuilder.issueNoOutput"); + case "duplicate-node": + return t("processing.modelBuilder.issueDuplicateNode"); + case "dangling-edge": + return t("processing.modelBuilder.issueDanglingEdge"); + default: + return issue.message; + } +} + +/** Display name for a port, translating the two synthetic node ports. */ +function portLabel(t: TFunction, label: string): string { + if (label === INPUT_NODE_PORT) return t("processing.modelBuilder.outputNode"); + if (label === OUTPUT_NODE_PORT) return t("processing.modelBuilder.inputNode"); + return label; +} + /** SVG layer drawing every connection, plus the in-progress link. */ function GraphEdges({ graph, @@ -1060,8 +1158,8 @@ function GraphNodeCard({ data-port="in" data-node-id={node.id} data-port-id={port.id} - title={port.label} - aria-label={t("processing.modelBuilder.inputPort", { port: port.label })} + title={portLabel(t, port.label)} + aria-label={t("processing.modelBuilder.inputPort", { port: portLabel(t, port.label) })} style={{ left: -6, top: at.y - node.y - 5 }} className="absolute h-2.5 w-2.5 rounded-full border border-primary bg-background" /> @@ -1076,8 +1174,8 @@ function GraphNodeCard({ data-port="out" data-node-id={node.id} data-port-id={port.id} - title={port.label} - aria-label={t("processing.modelBuilder.outputPort", { port: port.label })} + title={portLabel(t, port.label)} + aria-label={t("processing.modelBuilder.outputPort", { port: portLabel(t, port.label) })} onPointerDown={(event) => onPortPointerDown(event, node.id, port.id)} style={{ right: -6, top: at.y - node.y - 5 }} className="absolute h-2.5 w-2.5 cursor-crosshair rounded-full border border-primary bg-primary" @@ -1138,7 +1236,7 @@ function NodeInspector({ {issues.map((issue, index) => (

- {issue.message} + {translateIssue(t, issue)}

))} diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 48a5c809bc..6cb056ed7c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -4311,6 +4311,20 @@ "runModel": "تشغيل", "cancelRun": "إلغاء", "runCancelled": "تم إلغاء التشغيل.", + "issueMissingLayer": "اختر طبقة إدخال.", + "issueUnknownTool": "أداة غير معروفة «{{tool}}».", + "issueMissingInput": "«{{port}}» يحتاج إلى اتصال أو قيمة.", + "issueUnknownPort": "يشير أحد الاتصالات إلى منفذ لم يعد موجودًا.", + "issueDuplicateInput": "«{{port}}» لديه بالفعل اتصال وارد.", + "issueTypeMismatch": "هذان المنفذان يحملان أنواع بيانات مختلفة.", + "issueCycle": "النموذج يحتوي على حلقة مغلقة.", + "issueNoOutput": "أضف عقدة مخرجات للاحتفاظ بالنتيجة.", + "issueDuplicateNode": "عقدتان أو أكثر تشترك في المعرّف نفسه.", + "issueDanglingEdge": "يشير أحد الاتصالات إلى عقدة لم تعد موجودة.", + "outputAddFailed": "تعذّرت إضافة «{{name}}» إلى الخريطة", + "catalogUnavailable": "تعذّر تحميل الأدوات، لذا لا يمكن فحص النموذج أو تشغيله.", + "addToolNode": "إضافة {{tool}} إلى لوحة الرسم", + "importTooLarge": "هذا النموذج أكبر من أن يُفتح.", "importModel": "استيراد", "exportModel": "تصدير", "savedModels": "النماذج المحفوظة", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 4e59f6137b..2fc256ba01 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -4044,6 +4044,20 @@ "runModel": "Ausführen", "cancelRun": "Abbrechen", "runCancelled": "Ausführung abgebrochen.", + "issueMissingLayer": "Wählen Sie eine Eingabeebene.", + "issueUnknownTool": "Unbekanntes Werkzeug „{{tool}}“.", + "issueMissingInput": "„{{port}}“ benötigt eine Verbindung oder einen Wert.", + "issueUnknownPort": "Eine Verbindung verweist auf einen Anschluss, den es nicht mehr gibt.", + "issueDuplicateInput": "„{{port}}“ hat bereits eine eingehende Verbindung.", + "issueTypeMismatch": "Diese Anschlüsse führen unterschiedliche Datenarten.", + "issueCycle": "Das Modell enthält eine Schleife.", + "issueNoOutput": "Fügen Sie einen Ausgabeknoten hinzu, um ein Ergebnis zu behalten.", + "issueDuplicateNode": "Zwei oder mehr Knoten haben dieselbe ID.", + "issueDanglingEdge": "Eine Verbindung verweist auf einen Knoten, den es nicht mehr gibt.", + "outputAddFailed": "„{{name}}“ konnte nicht zur Karte hinzugefügt werden", + "catalogUnavailable": "Werkzeuge konnten nicht geladen werden; das Modell kann weder geprüft noch ausgeführt werden.", + "addToolNode": "{{tool}} zur Arbeitsfläche hinzufügen", + "importTooLarge": "Dieses Modell ist zu groß zum Öffnen.", "importModel": "Importieren", "exportModel": "Exportieren", "savedModels": "Gespeicherte Modelle", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 2686b44234..110571b7fd 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4054,6 +4054,20 @@ "runModel": "Run", "cancelRun": "Cancel", "runCancelled": "Run cancelled.", + "issueMissingLayer": "Choose an input layer.", + "issueUnknownTool": "Unknown tool \"{{tool}}\".", + "issueMissingInput": "\"{{port}}\" needs a connection or a value.", + "issueUnknownPort": "A connection refers to a port that no longer exists.", + "issueDuplicateInput": "\"{{port}}\" already has an incoming connection.", + "issueTypeMismatch": "Those ports carry different kinds of data.", + "issueCycle": "The model contains a loop.", + "issueNoOutput": "Add an output node to keep a result.", + "issueDuplicateNode": "Two or more nodes share the same id.", + "issueDanglingEdge": "A connection points at a node that no longer exists.", + "outputAddFailed": "Could not add \"{{name}}\" to the map", + "catalogUnavailable": "Tools could not be loaded, so the model cannot be checked or run.", + "addToolNode": "Add {{tool}} to the canvas", + "importTooLarge": "That model is too large to open.", "importModel": "Import", "exportModel": "Export", "savedModels": "Saved models", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 4ef8f49794..078fcbaf85 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -4044,6 +4044,20 @@ "runModel": "Ejecutar", "cancelRun": "Cancelar", "runCancelled": "Ejecución cancelada.", + "issueMissingLayer": "Elija una capa de entrada.", + "issueUnknownTool": "Herramienta desconocida «{{tool}}».", + "issueMissingInput": "«{{port}}» necesita una conexión o un valor.", + "issueUnknownPort": "Una conexión hace referencia a un puerto que ya no existe.", + "issueDuplicateInput": "«{{port}}» ya tiene una conexión entrante.", + "issueTypeMismatch": "Esos puertos transportan tipos de datos distintos.", + "issueCycle": "El modelo contiene un bucle.", + "issueNoOutput": "Añada un nodo de salida para conservar un resultado.", + "issueDuplicateNode": "Dos o más nodos comparten el mismo identificador.", + "issueDanglingEdge": "Una conexión apunta a un nodo que ya no existe.", + "outputAddFailed": "No se pudo añadir «{{name}}» al mapa", + "catalogUnavailable": "No se pudieron cargar las herramientas, así que el modelo no puede comprobarse ni ejecutarse.", + "addToolNode": "Añadir {{tool}} al lienzo", + "importTooLarge": "Ese modelo es demasiado grande para abrirlo.", "importModel": "Importar", "exportModel": "Exportar", "savedModels": "Modelos guardados", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 1321a6d29a..51f797c6ab 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -4044,6 +4044,20 @@ "runModel": "اجرا", "cancelRun": "لغو", "runCancelled": "اجرا لغو شد.", + "issueMissingLayer": "یک لایهٔ ورودی انتخاب کنید.", + "issueUnknownTool": "ابزار ناشناخته «{{tool}}».", + "issueMissingInput": "«{{port}}» به یک اتصال یا مقدار نیاز دارد.", + "issueUnknownPort": "یک اتصال به درگاهی اشاره می‌کند که دیگر وجود ندارد.", + "issueDuplicateInput": "«{{port}}» از پیش یک اتصال ورودی دارد.", + "issueTypeMismatch": "این درگاه‌ها انواع دادهٔ متفاوتی را حمل می‌کنند.", + "issueCycle": "مدل شامل یک حلقه است.", + "issueNoOutput": "برای نگه‌داشتن نتیجه، یک گرهٔ خروجی اضافه کنید.", + "issueDuplicateNode": "دو یا چند گره شناسهٔ یکسان دارند.", + "issueDanglingEdge": "یک اتصال به گرهی اشاره می‌کند که دیگر وجود ندارد.", + "outputAddFailed": "افزودن «{{name}}» به نقشه ممکن نشد", + "catalogUnavailable": "ابزارها بارگذاری نشدند، بنابراین مدل نه بررسی و نه اجرا می‌شود.", + "addToolNode": "افزودن {{tool}} به بوم", + "importTooLarge": "این مدل بزرگ‌تر از آن است که باز شود.", "importModel": "درون‌ریزی", "exportModel": "برون‌ریزی", "savedModels": "مدل‌های ذخیره‌شده", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 439e561f66..009c1d30ab 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -4044,6 +4044,20 @@ "runModel": "Exécuter", "cancelRun": "Annuler", "runCancelled": "Exécution annulée.", + "issueMissingLayer": "Choisissez une couche d'entrée.", + "issueUnknownTool": "Outil inconnu « {{tool}} ».", + "issueMissingInput": "« {{port}} » nécessite une connexion ou une valeur.", + "issueUnknownPort": "Une connexion renvoie à un port qui n'existe plus.", + "issueDuplicateInput": "« {{port}} » a déjà une connexion entrante.", + "issueTypeMismatch": "Ces ports transportent des types de données différents.", + "issueCycle": "Le modèle contient une boucle.", + "issueNoOutput": "Ajoutez un nœud de sortie pour conserver un résultat.", + "issueDuplicateNode": "Deux nœuds ou plus partagent le même identifiant.", + "issueDanglingEdge": "Une connexion renvoie à un nœud qui n'existe plus.", + "outputAddFailed": "Impossible d'ajouter « {{name}} » à la carte", + "catalogUnavailable": "Les outils n'ont pas pu être chargés ; le modèle ne peut être ni vérifié ni exécuté.", + "addToolNode": "Ajouter {{tool}} au canevas", + "importTooLarge": "Ce modèle est trop volumineux pour être ouvert.", "importModel": "Importer", "exportModel": "Exporter", "savedModels": "Modèles enregistrés", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 1f922a7aab..82d523bda4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -4044,6 +4044,20 @@ "runModel": "चलाएँ", "cancelRun": "रद्द करें", "runCancelled": "चलना रद्द किया गया।", + "issueMissingLayer": "एक इनपुट परत चुनें।", + "issueUnknownTool": "अज्ञात उपकरण \"{{tool}}\"।", + "issueMissingInput": "\"{{port}}\" को एक कनेक्शन या मान चाहिए।", + "issueUnknownPort": "एक कनेक्शन ऐसे पोर्ट को संदर्भित करता है जो अब मौजूद नहीं है।", + "issueDuplicateInput": "\"{{port}}\" के पास पहले से एक आने वाला कनेक्शन है।", + "issueTypeMismatch": "वे पोर्ट अलग-अलग प्रकार का डेटा ले जाते हैं।", + "issueCycle": "मॉडल में एक लूप है।", + "issueNoOutput": "परिणाम रखने के लिए एक आउटपुट नोड जोड़ें।", + "issueDuplicateNode": "दो या अधिक नोड एक ही id साझा करते हैं।", + "issueDanglingEdge": "एक कनेक्शन ऐसे नोड की ओर इशारा करता है जो अब मौजूद नहीं है।", + "outputAddFailed": "\"{{name}}\" को मानचित्र में नहीं जोड़ा जा सका", + "catalogUnavailable": "उपकरण लोड नहीं हो सके, इसलिए मॉडल की जाँच या उसे चलाया नहीं जा सकता।", + "addToolNode": "{{tool}} को कैनवास में जोड़ें", + "importTooLarge": "वह मॉडल खोलने के लिए बहुत बड़ा है।", "importModel": "आयात", "exportModel": "निर्यात", "savedModels": "सहेजे गए मॉडल", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index ec8c9edc87..bf2b21b442 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -3977,6 +3977,20 @@ "runModel": "Jalankan", "cancelRun": "Batal", "runCancelled": "Eksekusi dibatalkan.", + "issueMissingLayer": "Pilih lapisan masukan.", + "issueUnknownTool": "Alat tidak dikenal \"{{tool}}\".", + "issueMissingInput": "\"{{port}}\" memerlukan koneksi atau nilai.", + "issueUnknownPort": "Sebuah koneksi merujuk ke porta yang sudah tidak ada.", + "issueDuplicateInput": "\"{{port}}\" sudah memiliki koneksi masuk.", + "issueTypeMismatch": "Porta tersebut membawa jenis data yang berbeda.", + "issueCycle": "Model mengandung perulangan.", + "issueNoOutput": "Tambahkan simpul keluaran untuk menyimpan hasil.", + "issueDuplicateNode": "Dua simpul atau lebih memakai id yang sama.", + "issueDanglingEdge": "Sebuah koneksi menunjuk ke simpul yang sudah tidak ada.", + "outputAddFailed": "Tidak dapat menambahkan \"{{name}}\" ke peta", + "catalogUnavailable": "Alat gagal dimuat, sehingga model tidak dapat diperiksa atau dijalankan.", + "addToolNode": "Tambahkan {{tool}} ke kanvas", + "importTooLarge": "Model itu terlalu besar untuk dibuka.", "importModel": "Impor", "exportModel": "Ekspor", "savedModels": "Model tersimpan", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 93015ff8e9..84d59a9c0e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -4044,6 +4044,20 @@ "runModel": "Esegui", "cancelRun": "Annulla", "runCancelled": "Esecuzione annullata.", + "issueMissingLayer": "Scegli un livello di ingresso.", + "issueUnknownTool": "Strumento sconosciuto «{{tool}}».", + "issueMissingInput": "«{{port}}» richiede un collegamento o un valore.", + "issueUnknownPort": "Un collegamento fa riferimento a una porta che non esiste più.", + "issueDuplicateInput": "«{{port}}» ha già un collegamento in ingresso.", + "issueTypeMismatch": "Quelle porte trasportano tipi di dati diversi.", + "issueCycle": "Il modello contiene un ciclo.", + "issueNoOutput": "Aggiungi un nodo di uscita per conservare un risultato.", + "issueDuplicateNode": "Due o più nodi condividono lo stesso identificatore.", + "issueDanglingEdge": "Un collegamento punta a un nodo che non esiste più.", + "outputAddFailed": "Impossibile aggiungere «{{name}}» alla mappa", + "catalogUnavailable": "Non è stato possibile caricare gli strumenti, quindi il modello non può essere verificato né eseguito.", + "addToolNode": "Aggiungi {{tool}} all'area di lavoro", + "importTooLarge": "Questo modello è troppo grande da aprire.", "importModel": "Importa", "exportModel": "Esporta", "savedModels": "Modelli salvati", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 2df36aa0b5..f9f9dd310d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -3977,6 +3977,20 @@ "runModel": "実行", "cancelRun": "キャンセル", "runCancelled": "実行をキャンセルしました。", + "issueMissingLayer": "入力レイヤーを選択してください。", + "issueUnknownTool": "不明なツール「{{tool}}」。", + "issueMissingInput": "「{{port}}」には接続または値が必要です。", + "issueUnknownPort": "接続が存在しないポートを参照しています。", + "issueDuplicateInput": "「{{port}}」には既に入力接続があります。", + "issueTypeMismatch": "これらのポートは異なる種類のデータを扱います。", + "issueCycle": "モデルにループが含まれています。", + "issueNoOutput": "結果を保持するには出力ノードを追加してください。", + "issueDuplicateNode": "2 つ以上のノードが同じ id を使用しています。", + "issueDanglingEdge": "接続が存在しないノードを指しています。", + "outputAddFailed": "「{{name}}」を地図に追加できませんでした", + "catalogUnavailable": "ツールを読み込めなかったため、モデルの確認も実行もできません。", + "addToolNode": "{{tool}} をキャンバスに追加", + "importTooLarge": "このモデルは大きすぎて開けません。", "importModel": "インポート", "exportModel": "エクスポート", "savedModels": "保存済みのモデル", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index 507560e9a7..bf02d61be0 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -4044,6 +4044,20 @@ "runModel": "გაშვება", "cancelRun": "გაუქმება", "runCancelled": "გაშვება გაუქმდა.", + "issueMissingLayer": "აირჩიეთ შემავალი ფენა.", + "issueUnknownTool": "უცნობი ხელსაწყო „{{tool}}“.", + "issueMissingInput": "„{{port}}“ საჭიროებს კავშირს ან მნიშვნელობას.", + "issueUnknownPort": "კავშირი მიუთითებს პორტზე, რომელიც აღარ არსებობს.", + "issueDuplicateInput": "„{{port}}“-ს უკვე აქვს შემომავალი კავშირი.", + "issueTypeMismatch": "ეს პორტები სხვადასხვა ტიპის მონაცემს ატარებს.", + "issueCycle": "მოდელი შეიცავს მარყუჟს.", + "issueNoOutput": "შედეგის შესანახად დაამატეთ გამომავალი კვანძი.", + "issueDuplicateNode": "ორ ან მეტ კვანძს ერთი და იგივე id აქვს.", + "issueDanglingEdge": "კავშირი მიუთითებს კვანძზე, რომელიც აღარ არსებობს.", + "outputAddFailed": "„{{name}}“ ვერ დაემატა რუკას", + "catalogUnavailable": "ხელსაწყოები ვერ ჩაიტვირთა, ამიტომ მოდელის შემოწმება ან გაშვება ვერ მოხერხდება.", + "addToolNode": "{{tool}}-ის დამატება ტილოზე", + "importTooLarge": "ეს მოდელი ძალიან დიდია გასახსნელად.", "importModel": "იმპორტი", "exportModel": "ექსპორტი", "savedModels": "შენახული მოდელები", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 5b7fd14384..222fbd5745 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -3977,6 +3977,20 @@ "runModel": "실행", "cancelRun": "취소", "runCancelled": "실행을 취소했습니다.", + "issueMissingLayer": "입력 레이어를 선택하세요.", + "issueUnknownTool": "알 수 없는 도구 \"{{tool}}\".", + "issueMissingInput": "\"{{port}}\"에는 연결 또는 값이 필요합니다.", + "issueUnknownPort": "연결이 더 이상 존재하지 않는 포트를 가리킵니다.", + "issueDuplicateInput": "\"{{port}}\"에는 이미 들어오는 연결이 있습니다.", + "issueTypeMismatch": "해당 포트들은 서로 다른 종류의 데이터를 전달합니다.", + "issueCycle": "모델에 순환이 있습니다.", + "issueNoOutput": "결과를 남기려면 출력 노드를 추가하세요.", + "issueDuplicateNode": "두 개 이상의 노드가 같은 id를 사용합니다.", + "issueDanglingEdge": "연결이 더 이상 존재하지 않는 노드를 가리킵니다.", + "outputAddFailed": "\"{{name}}\"을(를) 지도에 추가하지 못했습니다", + "catalogUnavailable": "도구를 불러오지 못해 모델을 확인하거나 실행할 수 없습니다.", + "addToolNode": "{{tool}}을(를) 캔버스에 추가", + "importTooLarge": "이 모델은 너무 커서 열 수 없습니다.", "importModel": "가져오기", "exportModel": "내보내기", "savedModels": "저장된 모델", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 337ba48a4b..77e4eb8b19 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -4044,6 +4044,20 @@ "runModel": "Uitvoeren", "cancelRun": "Annuleren", "runCancelled": "Uitvoeren geannuleerd.", + "issueMissingLayer": "Kies een invoerlaag.", + "issueUnknownTool": "Onbekend gereedschap “{{tool}}”.", + "issueMissingInput": "“{{port}}” heeft een verbinding of een waarde nodig.", + "issueUnknownPort": "Een verbinding verwijst naar een poort die niet meer bestaat.", + "issueDuplicateInput": "“{{port}}” heeft al een inkomende verbinding.", + "issueTypeMismatch": "Die poorten dragen verschillende soorten gegevens.", + "issueCycle": "Het model bevat een lus.", + "issueNoOutput": "Voeg een uitvoerknooppunt toe om een resultaat te bewaren.", + "issueDuplicateNode": "Twee of meer knooppunten delen dezelfde id.", + "issueDanglingEdge": "Een verbinding wijst naar een knooppunt dat niet meer bestaat.", + "outputAddFailed": "Kon “{{name}}” niet aan de kaart toevoegen", + "catalogUnavailable": "Gereedschappen konden niet worden geladen, dus het model kan niet worden gecontroleerd of uitgevoerd.", + "addToolNode": "{{tool}} aan het canvas toevoegen", + "importTooLarge": "Dat model is te groot om te openen.", "importModel": "Importeren", "exportModel": "Exporteren", "savedModels": "Opgeslagen modellen", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index f8c95e1609..c071ba8e1f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -4044,6 +4044,20 @@ "runModel": "Executar", "cancelRun": "Cancelar", "runCancelled": "Execução cancelada.", + "issueMissingLayer": "Escolha uma camada de entrada.", + "issueUnknownTool": "Ferramenta desconhecida «{{tool}}».", + "issueMissingInput": "«{{port}}» precisa de uma ligação ou de um valor.", + "issueUnknownPort": "Uma ligação refere-se a uma porta que já não existe.", + "issueDuplicateInput": "«{{port}}» já tem uma ligação de entrada.", + "issueTypeMismatch": "Essas portas transportam tipos de dados diferentes.", + "issueCycle": "O modelo contém um ciclo.", + "issueNoOutput": "Adicione um nó de saída para guardar um resultado.", + "issueDuplicateNode": "Dois ou mais nós partilham o mesmo identificador.", + "issueDanglingEdge": "Uma ligação aponta para um nó que já não existe.", + "outputAddFailed": "Não foi possível adicionar «{{name}}» ao mapa", + "catalogUnavailable": "Não foi possível carregar as ferramentas, por isso o modelo não pode ser verificado nem executado.", + "addToolNode": "Adicionar {{tool}} à tela", + "importTooLarge": "Esse modelo é demasiado grande para abrir.", "importModel": "Importar", "exportModel": "Exportar", "savedModels": "Modelos guardados", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 0f812398a5..d8c72b89ab 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -4178,6 +4178,20 @@ "runModel": "Запустить", "cancelRun": "Отмена", "runCancelled": "Выполнение отменено.", + "issueMissingLayer": "Выберите входной слой.", + "issueUnknownTool": "Неизвестный инструмент «{{tool}}».", + "issueMissingInput": "«{{port}}» требует связи или значения.", + "issueUnknownPort": "Связь ссылается на порт, которого больше нет.", + "issueDuplicateInput": "«{{port}}» уже имеет входящую связь.", + "issueTypeMismatch": "Эти порты передают разные типы данных.", + "issueCycle": "Модель содержит цикл.", + "issueNoOutput": "Добавьте выходной узел, чтобы сохранить результат.", + "issueDuplicateNode": "Два или более узла имеют одинаковый id.", + "issueDanglingEdge": "Связь указывает на узел, которого больше нет.", + "outputAddFailed": "Не удалось добавить «{{name}}» на карту", + "catalogUnavailable": "Не удалось загрузить инструменты, поэтому модель нельзя проверить или запустить.", + "addToolNode": "Добавить {{tool}} на холст", + "importTooLarge": "Эта модель слишком велика, чтобы её открыть.", "importModel": "Импорт", "exportModel": "Экспорт", "savedModels": "Сохранённые модели", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index df84f0344f..579f0d9304 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -3977,6 +3977,20 @@ "runModel": "เรียกใช้", "cancelRun": "ยกเลิก", "runCancelled": "ยกเลิกการเรียกใช้แล้ว", + "issueMissingLayer": "เลือกชั้นข้อมูลนำเข้า", + "issueUnknownTool": "ไม่รู้จักเครื่องมือ \"{{tool}}\"", + "issueMissingInput": "\"{{port}}\" ต้องการการเชื่อมต่อหรือค่า", + "issueUnknownPort": "การเชื่อมต่อหนึ่งอ้างถึงพอร์ตที่ไม่มีอยู่แล้ว", + "issueDuplicateInput": "\"{{port}}\" มีการเชื่อมต่อขาเข้าอยู่แล้ว", + "issueTypeMismatch": "พอร์ตเหล่านั้นรับส่งข้อมูลคนละชนิด", + "issueCycle": "แบบจำลองมีวงวน", + "issueNoOutput": "เพิ่มโหนดเอาต์พุตเพื่อเก็บผลลัพธ์", + "issueDuplicateNode": "มีโหนดตั้งแต่สองโหนดขึ้นไปใช้ id เดียวกัน", + "issueDanglingEdge": "การเชื่อมต่อหนึ่งชี้ไปยังโหนดที่ไม่มีอยู่แล้ว", + "outputAddFailed": "ไม่สามารถเพิ่ม \"{{name}}\" ลงในแผนที่", + "catalogUnavailable": "โหลดเครื่องมือไม่สำเร็จ จึงไม่สามารถตรวจสอบหรือเรียกใช้แบบจำลองได้", + "addToolNode": "เพิ่ม {{tool}} ลงในพื้นที่ทำงาน", + "importTooLarge": "แบบจำลองนั้นใหญ่เกินกว่าจะเปิดได้", "importModel": "นำเข้า", "exportModel": "ส่งออก", "savedModels": "แบบจำลองที่บันทึกไว้", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 20762ceb28..bfbe2b79df 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -4044,6 +4044,20 @@ "runModel": "Çalıştır", "cancelRun": "İptal", "runCancelled": "Çalıştırma iptal edildi.", + "issueMissingLayer": "Bir girdi katmanı seçin.", + "issueUnknownTool": "Bilinmeyen araç \"{{tool}}\".", + "issueMissingInput": "\"{{port}}\" bir bağlantı veya değer gerektiriyor.", + "issueUnknownPort": "Bir bağlantı artık var olmayan bir bağlantı noktasına işaret ediyor.", + "issueDuplicateInput": "\"{{port}}\" zaten bir gelen bağlantıya sahip.", + "issueTypeMismatch": "Bu bağlantı noktaları farklı türde veri taşıyor.", + "issueCycle": "Model bir döngü içeriyor.", + "issueNoOutput": "Bir sonucu saklamak için çıktı düğümü ekleyin.", + "issueDuplicateNode": "İki veya daha fazla düğüm aynı id'yi paylaşıyor.", + "issueDanglingEdge": "Bir bağlantı artık var olmayan bir düğüme işaret ediyor.", + "outputAddFailed": "\"{{name}}\" haritaya eklenemedi", + "catalogUnavailable": "Araçlar yüklenemedi, bu yüzden model denetlenemez veya çalıştırılamaz.", + "addToolNode": "{{tool}} aracını tuvale ekle", + "importTooLarge": "Bu model açılamayacak kadar büyük.", "importModel": "İçe aktar", "exportModel": "Dışa aktar", "savedModels": "Kayıtlı modeller", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index b0e7da7427..ce07cb3105 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -4063,6 +4063,20 @@ "runModel": "Chạy", "cancelRun": "Hủy", "runCancelled": "Đã hủy lần chạy.", + "issueMissingLayer": "Chọn một lớp đầu vào.", + "issueUnknownTool": "Công cụ không xác định \"{{tool}}\".", + "issueMissingInput": "\"{{port}}\" cần một kết nối hoặc một giá trị.", + "issueUnknownPort": "Một kết nối tham chiếu tới cổng không còn tồn tại.", + "issueDuplicateInput": "\"{{port}}\" đã có một kết nối đến.", + "issueTypeMismatch": "Các cổng đó mang những loại dữ liệu khác nhau.", + "issueCycle": "Mô hình có chứa vòng lặp.", + "issueNoOutput": "Thêm một nút đầu ra để giữ lại kết quả.", + "issueDuplicateNode": "Hai hoặc nhiều nút dùng chung một id.", + "issueDanglingEdge": "Một kết nối trỏ tới nút không còn tồn tại.", + "outputAddFailed": "Không thể thêm \"{{name}}\" vào bản đồ", + "catalogUnavailable": "Không tải được công cụ nên không thể kiểm tra hay chạy mô hình.", + "addToolNode": "Thêm {{tool}} vào khung vẽ", + "importTooLarge": "Mô hình đó quá lớn để mở.", "importModel": "Nhập", "exportModel": "Xuất", "savedModels": "Mô hình đã lưu", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 234d2f21fd..e3a6164e77 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -3977,6 +3977,20 @@ "runModel": "运行", "cancelRun": "取消", "runCancelled": "已取消运行。", + "issueMissingLayer": "请选择输入图层。", + "issueUnknownTool": "未知工具“{{tool}}”。", + "issueMissingInput": "“{{port}}”需要一个连接或一个值。", + "issueUnknownPort": "某个连接引用了已不存在的端口。", + "issueDuplicateInput": "“{{port}}”已有一个传入连接。", + "issueTypeMismatch": "这些端口承载的数据类型不同。", + "issueCycle": "模型中存在环路。", + "issueNoOutput": "请添加输出节点以保留结果。", + "issueDuplicateNode": "有两个或多个节点使用了相同的 id。", + "issueDanglingEdge": "某个连接指向了已不存在的节点。", + "outputAddFailed": "无法将“{{name}}”添加到地图", + "catalogUnavailable": "无法加载工具,因此无法检查或运行该模型。", + "addToolNode": "将 {{tool}} 添加到画布", + "importTooLarge": "该模型过大,无法打开。", "importModel": "导入", "exportModel": "导出", "savedModels": "已保存的模型", diff --git a/apps/geolibre-desktop/src/lib/model-graph-edit.ts b/apps/geolibre-desktop/src/lib/model-graph-edit.ts index d320396cab..e6d644106a 100644 --- a/apps/geolibre-desktop/src/lib/model-graph-edit.ts +++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts @@ -280,18 +280,40 @@ export function autoLayout(graph: ProcessingModelGraph): ProcessingModelGraph { list.push(edge.from); incoming.set(edge.to, list); } - const resolveDepth = (nodeId: string, seen: Set): number => { - if (depth.has(nodeId)) return depth.get(nodeId) as number; - if (seen.has(nodeId)) return 0; - seen.add(nodeId); - const parents = incoming.get(nodeId) ?? []; - const value = parents.length - ? Math.max(...parents.map((parent) => resolveDepth(parent, seen) + 1)) - : 0; - depth.set(nodeId, value); - return value; + // Iterative rather than recursive: this runs on an imported file before any + // size or cycle check, so a very long ancestor chain would otherwise exhaust + // the call stack instead of failing gracefully. + const resolveDepth = (start: string): number => { + const stack: string[] = [start]; + const onStack = new Set([start]); + while (stack.length > 0) { + const nodeId = stack[stack.length - 1]; + if (depth.has(nodeId)) { + stack.pop(); + onStack.delete(nodeId); + continue; + } + const parents = incoming.get(nodeId) ?? []; + // A parent still on the stack is a cycle; treat it as contributing no + // depth rather than looping forever. + const pending = parents.filter((parent) => !depth.has(parent) && !onStack.has(parent)); + if (pending.length > 0) { + for (const parent of pending) { + stack.push(parent); + onStack.add(parent); + } + continue; + } + const value = parents.length + ? Math.max(0, ...parents.map((parent) => (depth.get(parent) ?? 0) + 1)) + : 0; + depth.set(nodeId, value); + stack.pop(); + onStack.delete(nodeId); + } + return depth.get(start) ?? 0; }; - for (const node of graph.nodes) resolveDepth(node.id, new Set()); + for (const node of graph.nodes) resolveDepth(node.id); const perColumn = new Map(); return { ...graph, diff --git a/packages/processing/src/model-graph.ts b/packages/processing/src/model-graph.ts index 7b08d791fb..80357cf26e 100644 --- a/packages/processing/src/model-graph.ts +++ b/packages/processing/src/model-graph.ts @@ -17,7 +17,13 @@ export type ModelValue = | { kind: "vector"; geojson: FeatureCollection } | { kind: "raster"; bytes: Uint8Array; name?: string }; -/** One connection point on a {@link ModelToolDescriptor}. */ +/** + * One connection point on a {@link ModelToolDescriptor}. + * + * `label` is a stable identifier, not display text: this package has no i18n + * access, so a hardcoded English word here would reach the rendered port title + * untranslated in every locale. The UI layer resolves it for display. + */ export interface ModelToolPort { /** * For an input port this is the underlying tool parameter id, so wiring an @@ -85,7 +91,13 @@ export interface ModelGraphIssue { | "dangling-edge"; nodeId?: string; edgeId?: string; - /** English fallback describing the problem. */ + /** + * The one piece of data the message names — a port label or a tool id — so + * the UI can interpolate it into a translated string instead of parsing it + * back out of {@link message}. + */ + detail?: string; + /** English fallback, for a caller with no translation for {@link code}. */ message: string; } @@ -139,11 +151,11 @@ function portsFor( descriptor: ModelToolDescriptor | undefined, ): { inputs: ModelToolPort[]; outputs: ModelToolPort[] } { if (node.kind === "input") { - return { inputs: [], outputs: [{ id: INPUT_NODE_PORT, label: "Output", kind: "any" }] }; + return { inputs: [], outputs: [{ id: INPUT_NODE_PORT, label: INPUT_NODE_PORT, kind: "any" }] }; } if (node.kind === "output") { return { - inputs: [{ id: OUTPUT_NODE_PORT, label: "Input", kind: "any", required: true }], + inputs: [{ id: OUTPUT_NODE_PORT, label: OUTPUT_NODE_PORT, kind: "any", required: true }], outputs: [], }; } @@ -196,6 +208,7 @@ export function validateModelGraph( issues.push({ code: "unknown-tool", nodeId: node.id, + detail: node.toolId ?? "", message: `Unknown tool "${node.toolId ?? ""}"`, }); } @@ -250,6 +263,7 @@ export function validateModelGraph( issues.push({ code: "duplicate-input", edgeId: edge.id, + detail: toPort.label, message: `"${toPort.label}" already has an incoming connection.`, }); } @@ -269,6 +283,7 @@ export function validateModelGraph( issues.push({ code: "missing-input", nodeId: node.id, + detail: port.label, message: `"${port.label}" needs a connection or a value.`, }); } diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts index 3a1127616e..7f97eb1534 100644 --- a/tests/model-graph-edit.test.ts +++ b/tests/model-graph-edit.test.ts @@ -282,6 +282,45 @@ describe("auto layout", () => { assert.deepEqual(autoLayout(graph), graph); }); + it("handles a long chain iteratively instead of exhausting the stack", () => { + // An imported file is laid out before any size or cycle check, so depth + // resolution has to survive a chain far longer than the call stack allows. + const n = 20000; + const nodes = Array.from({ length: n }, (_, i) => ({ + id: `n${i}`, + kind: "tool" as const, + x: 0, + y: 0, + provider: "vector" as const, + toolId: "buffer", + })); + const edges = Array.from({ length: n - 1 }, (_, i) => ({ + id: `e${i}`, + from: `n${i}`, + fromPort: "out", + to: `n${i + 1}`, + toPort: "layer", + })); + const laid = autoLayout({ nodes, edges }); + assert.equal(laid.nodes.length, n); + // Depth increases along the chain, so the last node sits far to the right. + assert.ok(laid.nodes[n - 1].x > laid.nodes[0].x); + }); + + it("does not hang on a cycle with no root to start from", () => { + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + ], + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "a", toPort: "layer" }, + ], + }; + assert.equal(autoLayout(graph).nodes.length, 2); + }); + it("stacks siblings of the same depth into separate rows", () => { const graph: ProcessingModelGraph = { nodes: [ diff --git a/tests/model-graph.test.ts b/tests/model-graph.test.ts index eb66f09b6e..e8f76315e9 100644 --- a/tests/model-graph.test.ts +++ b/tests/model-graph.test.ts @@ -231,6 +231,29 @@ describe("model graph validation", () => { assert.ok(codes.includes("dangling-edge")); }); + it("carries the offending port or tool id as `detail` for interpolation", () => { + // The UI translates by `code` and interpolates `detail`; without it the port + // name would have to be parsed back out of the English message. + const graph: ProcessingModelGraph = { + nodes: [ + { id: "c", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "clip" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [{ id: "e1", from: "c", fromPort: "out", to: "o", toPort: "in" }], + }; + const details = validateModelGraph(graph, resolve) + .filter((issue) => issue.code === "missing-input") + .map((issue) => issue.detail); + assert.deepEqual(details.sort(), ["Clip layer", "Input"]); + + const unknown = chainGraph(); + unknown.nodes[1].toolId = "nope"; + assert.equal( + validateModelGraph(unknown, resolve).find((i) => i.code === "unknown-tool")?.detail, + "nope", + ); + }); + it("requires an output node so a run keeps something", () => { const graph = chainGraph(); graph.nodes = graph.nodes.filter((node) => node.kind !== "output"); From 3538385a3948d346ae31490d3e71746accc8a63c Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 17 Aug 2026 23:48:18 -0400 Subject: [PATCH 14/22] Address review feedback - Await the host's raster adds before logging the run summary. They are fire-and-forget promises that settle after runModelGraph returns, so the failure count was always read as zero and the summary claimed outputs that were about to fail, contradicted a moment later by the error line. - Stop catalogFailed latching for the session. The load effect was gated on catalog.length, which VECTOR_TOOLS alone makes non-empty, so a first attempt where both remote sources failed never retried and left Run disabled even for a pure client-side model. It now gates on a real loaded flag that only a successful attempt sets, reopening the panel retries, and the message carries a Retry button. - Drop the last unused icon imports from BatchToolsDialog. My previous trim was line-based and missed them once the import collapsed onto one line. --- .../processing/BatchToolsDialog.tsx | 2 +- .../model-builder/ModelBuilderPanel.tsx | 64 +++++++++++++------ 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx index bc21bdc0b2..c86f2993e2 100644 --- a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx @@ -23,7 +23,7 @@ import { Select, } from "@geolibre/ui"; import { ParameterField } from "./ParameterField"; -import { Download, Layers, Loader2, Play, Plus } from "lucide-react"; +import { Loader2, Play } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; interface BatchToolsDialogProps { diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index e773f0ce19..fbc9fad11b 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -132,6 +132,9 @@ export function ModelBuilderPanel({ const [selectedNodeId, setSelectedNodeId] = useState(null); const [catalog, setCatalog] = useState([]); const [catalogFailed, setCatalogFailed] = useState(false); + const [catalogLoaded, setCatalogLoaded] = useState(false); + /** Bumped to re-run the catalog load after a failure. */ + const [retryToken, setRetryToken] = useState(0); const [search, setSearch] = useState(""); const [log, setLog] = useState([]); const [running, setRunning] = useState(false); @@ -169,7 +172,7 @@ export function ModelBuilderPanel({ // concurrently and each degrades independently: losing one still leaves a // usable palette built from the other. useEffect(() => { - if (!open || catalog.length > 0) return; + if (!open || catalogLoaded) return; let cancelled = false; void (async () => { const [catalogResult, wasmResult] = await Promise.allSettled([ @@ -196,12 +199,18 @@ export function ModelBuilderPanel({ ); // Both registries failing leaves a palette that can resolve nothing, which // must not read as "no problems found" on a canvas full of tool nodes. - setCatalogFailed(catalogResult.status === "rejected" && wasmResult.status === "rejected"); + const bothFailed = catalogResult.status === "rejected" && wasmResult.status === "rejected"; + setCatalogFailed(bothFailed); + // Only a successful load closes the door on retrying. Gating on + // `catalog.length` instead would latch after the first attempt, since + // VECTOR_TOOLS alone makes the catalog non-empty even when both remote + // sources failed — leaving no way back short of reloading the app. + setCatalogLoaded(!bothFailed); })(); return () => { cancelled = true; }; - }, [open, catalog.length]); + }, [open, catalogLoaded, retryToken]); const descriptorByKey = useMemo( () => new Map(catalog.map((descriptor) => [descriptor.key, descriptor])), @@ -515,8 +524,11 @@ export function ModelBuilderPanel({ setNodeStatus({}); const duckdb = createDuckDbCapability(); // Outputs the host refused after the graph itself finished, so the summary - // does not claim success for a layer that never reached the map. + // does not claim success for a layer that never reached the map. The adds + // are awaited before the summary is logged, since they settle after + // runModelGraph returns. const failedOutputs: string[] = []; + const pendingAdds: Promise[] = []; try { const result = await runModelGraph(graph, { resolveDescriptor, @@ -527,18 +539,20 @@ export function ModelBuilderPanel({ if (value.kind === "vector") { addGeoJsonLayer(name, value.geojson); } else if (onAddRaster) { - void Promise.resolve( - onAddRaster(value.bytes, name, `${name.replace(/\s+/g, "_")}.tif`), - ).catch((err: unknown) => { - // Otherwise this is an unhandled rejection and the run still - // reports success for an output that never reached the map. - failedOutputs.push(name); - appendLog( - `${t("processing.modelBuilder.outputAddFailed", { name })}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }); + pendingAdds.push( + Promise.resolve( + onAddRaster(value.bytes, name, `${name.replace(/\s+/g, "_")}.tif`), + ).catch((err: unknown) => { + // Otherwise this is an unhandled rejection and the run still + // reports success for an output that never reached the map. + failedOutputs.push(name); + appendLog( + `${t("processing.modelBuilder.outputAddFailed", { name })}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }), + ); } else { appendLog(t("processing.modelBuilder.rasterOutputUnsupported", { name })); } @@ -562,6 +576,9 @@ export function ModelBuilderPanel({ log: appendLog, }), }); + // Wait for the host's raster adds before summarising: they settle after + // runModelGraph returns, so counting failures first would always read 0. + await Promise.allSettled(pendingAdds); if (!controller.signal.aborted) { appendLog( result.error @@ -937,8 +954,19 @@ export function ModelBuilderPanel({
{catalogFailed && ( -
- {t("processing.modelBuilder.catalogUnavailable")} +
+ {t("processing.modelBuilder.catalogUnavailable")} +
)} {issues.map((issue, index) => ( From 96563547a3d74a87bc4041671fa12bc2bbe5c000 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 00:07:27 -0400 Subject: [PATCH 15/22] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use the port id constants in the panel's local portsOf, mirroring the engine's portsFor. I fixed the engine last round but left this copy returning the display strings "Output"/"Input", so portLabel's comparison against the ids never matched and the English word reached the port title and aria-label in every locale — the exact leak that change was meant to close. - Retitle the Command Palette's Model Builder entry, which still read "Batch & Models" in all 19 catalogs because toolbar.command.modelBuilder is a separate key from toolbar.item.modelBuilder, and register a Batch tools command so that dialog is reachable from the palette at all. Keywords now match each entry rather than mixing both concepts. - Put the Batch dialog's "Run batch" through t(), with the key added to every catalog. --- .../src/components/layout/TopToolbar.tsx | 12 +++++++++++- .../src/components/processing/BatchToolsDialog.tsx | 2 +- .../processing/model-builder/ModelBuilderPanel.tsx | 10 ++++++++-- apps/geolibre-desktop/src/i18n/locales/ar.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/de.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/en.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/es.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/fa.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/fr.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/hi.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/id.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/it.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/ja.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/ka.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/ko.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/nl.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/pt.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/ru.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/th.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/tr.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/vi.json | 4 +++- apps/geolibre-desktop/src/i18n/locales/zh.json | 4 +++- 22 files changed, 77 insertions(+), 23 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 37821e55a1..3de79a8884 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -86,6 +86,7 @@ import { Save, Sparkles, Sun, + Layers, Workflow, Wrench, ZoomIn, @@ -950,6 +951,7 @@ export function TopToolbar({ const setVectorToolOpen = useAppStore((s) => s.setVectorToolOpen); const setGeocodeOpen = useAppStore((s) => s.setGeocodeOpen); const setModelBuilderOpen = useAppStore((s) => s.setModelBuilderOpen); + const setBatchToolsOpen = useAppStore((s) => s.setBatchToolsOpen); const setStyleManagerOpen = useAppStore((s) => s.setStyleManagerOpen); const setRasterToolOpen = useAppStore((s) => s.setRasterToolOpen); const setSegmentationOpen = useAppStore((s) => s.setSegmentationOpen); @@ -1426,10 +1428,18 @@ export function TopToolbar({ id: "proc.modelBuilder", title: t("toolbar.command.modelBuilder"), group: t("toolbar.commandGroup.processing"), - keywords: "batch model pipeline chain modeler workflow graphical", + keywords: "model builder pipeline chain modeler workflow graph canvas node", icon: Workflow, run: () => setModelBuilderOpen(true), }, + { + id: "proc.batchTools", + title: t("toolbar.command.batchTools"), + group: t("toolbar.commandGroup.processing"), + keywords: "batch bulk many layers repeat vector tool", + icon: Layers, + run: () => setBatchToolsOpen(true), + }, // The Mac App Store build omits AI Segmentation: it is sidecar-only (the // App Sandbox forbids the sidecar) and has no client-side fallback. ...(IS_MAS_BUILD diff --git a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx index c86f2993e2..915cba6600 100644 --- a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx @@ -409,7 +409,7 @@ function BatchPanel({ mapControllerRef }: BatchToolsDialogProps): ReactElement {
diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index fbc9fad11b..1502d36c58 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -1114,8 +1114,14 @@ function portsOf( node: ModelGraphNode, descriptor: ModelToolDescriptor | undefined, ): { inputs: { id: string; label: string }[]; outputs: { id: string; label: string }[] } { - if (node.kind === "input") return { inputs: [], outputs: [{ id: "out", label: "Output" }] }; - if (node.kind === "output") return { inputs: [{ id: "in", label: "Input" }], outputs: [] }; + // Stable ids, matching the engine's own portsFor: portLabel() resolves these + // for display, and a hardcoded English word here would slip past it. + if (node.kind === "input") { + return { inputs: [], outputs: [{ id: INPUT_NODE_PORT, label: INPUT_NODE_PORT }] }; + } + if (node.kind === "output") { + return { inputs: [{ id: OUTPUT_NODE_PORT, label: OUTPUT_NODE_PORT }], outputs: [] }; + } return { inputs: descriptor?.inputs ?? [], outputs: descriptor?.outputs ?? [] }; } diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 6cb056ed7c..c8792fbdf9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -2394,7 +2394,8 @@ "dashboard": "لوحة المعلومات", "assistant": "مساعد الذكاء الاصطناعي", "geocode": "الترميز الجغرافي للعناوين", - "modelBuilder": "المعالجة الدفعية والنماذج", + "batchTools": "أدوات الدفعات", + "modelBuilder": "منشئ النماذج", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "التجزئة بالذكاء الاصطناعي", @@ -4292,6 +4293,7 @@ "batchTools": { "title": "أدوات الدفعات", "description": "تشغيل أداة متجهية واحدة على عدة طبقات دفعة واحدة.", + "runBatch": "تشغيل الدفعة", "tool": "الأداة", "sharedParameters": "المعاملات المشتركة", "noExtraParameters": "لا تحتوي هذه الأداة على معاملات إضافية.", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 2fc256ba01..2a12272561 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -2215,7 +2215,8 @@ "dashboard": "Dashboard", "assistant": "KI-Assistent", "geocode": "Adressen geokodieren", - "modelBuilder": "Stapel & Modelle", + "batchTools": "Stapelwerkzeuge", + "modelBuilder": "Modellbaukasten", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "KI-Segmentierung", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Stapelwerkzeuge", "description": "Ein Vektorwerkzeug auf viele Ebenen gleichzeitig anwenden.", + "runBatch": "Stapel ausführen", "tool": "Werkzeug", "sharedParameters": "Gemeinsame Parameter", "noExtraParameters": "Dieses Werkzeug hat keine zusätzlichen Parameter.", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 110571b7fd..0f097b8850 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -2225,7 +2225,8 @@ "dashboard": "Dashboard", "assistant": "AI Assistant", "geocode": "Geocode Addresses", - "modelBuilder": "Batch & Models", + "batchTools": "Batch tools", + "modelBuilder": "Model Builder", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI Segmentation", @@ -4035,6 +4036,7 @@ "batchTools": { "title": "Batch tools", "description": "Run one vector tool across many layers at once.", + "runBatch": "Run batch", "tool": "Tool", "sharedParameters": "Shared parameters", "noExtraParameters": "This tool has no extra parameters.", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 078fcbaf85..119092a6c8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -2215,7 +2215,8 @@ "dashboard": "Panel de control", "assistant": "Asistente de IA", "geocode": "Geocodificar direcciones", - "modelBuilder": "Lotes y modelos", + "batchTools": "Herramientas por lotes", + "modelBuilder": "Constructor de modelos", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "Segmentación con IA", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Herramientas por lotes", "description": "Ejecutar una herramienta vectorial sobre muchas capas a la vez.", + "runBatch": "Ejecutar lote", "tool": "Herramienta", "sharedParameters": "Parámetros compartidos", "noExtraParameters": "Esta herramienta no tiene parámetros adicionales.", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 51f797c6ab..a872f3dd7c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -2215,7 +2215,8 @@ "dashboard": "داشبورد", "assistant": "دستیار هوش مصنوعی", "geocode": "مکان‌یابی نشانی‌ها", - "modelBuilder": "دسته‌ای و مدل‌ها", + "batchTools": "ابزارهای دسته‌ای", + "modelBuilder": "سازندهٔ مدل", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "قطعه‌بندی هوش مصنوعی", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "ابزارهای دسته‌ای", "description": "اجرای یک ابزار برداری روی چند لایه به‌صورت یکجا.", + "runBatch": "اجرای دسته", "tool": "ابزار", "sharedParameters": "پارامترهای مشترک", "noExtraParameters": "این ابزار پارامتر افزوده‌ای ندارد.", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 009c1d30ab..9c7dc7d737 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -2215,7 +2215,8 @@ "dashboard": "Tableau de bord", "assistant": "Assistant IA", "geocode": "Géocoder des adresses", - "modelBuilder": "Lots et modèles", + "batchTools": "Outils par lot", + "modelBuilder": "Générateur de modèles", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "Segmentation IA", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Outils par lot", "description": "Exécuter un outil vectoriel sur plusieurs couches à la fois.", + "runBatch": "Exécuter le lot", "tool": "Outil", "sharedParameters": "Paramètres partagés", "noExtraParameters": "Cet outil n'a pas de paramètres supplémentaires.", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 82d523bda4..22ddc7816c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -2215,7 +2215,8 @@ "dashboard": "डैशबोर्ड", "assistant": "AI सहायक", "geocode": "पते जियोकोड करें", - "modelBuilder": "बैच और मॉडल", + "batchTools": "बैच उपकरण", + "modelBuilder": "मॉडल बिल्डर", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI सेगमेंटेशन", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "बैच उपकरण", "description": "एक ही वेक्टर उपकरण को कई परतों पर एक साथ चलाएँ।", + "runBatch": "बैच चलाएँ", "tool": "टूल", "sharedParameters": "साझा पैरामीटर", "noExtraParameters": "इस टूल में कोई अतिरिक्त पैरामीटर नहीं है।", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index bf2b21b442..9838b9acfb 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -2170,7 +2170,8 @@ "dashboard": "Dasbor", "assistant": "Asisten AI", "geocode": "Geocode Alamat", - "modelBuilder": "Batch & Model", + "batchTools": "Alat massal", + "modelBuilder": "Pembuat Model", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "Segmentasi AI", @@ -3958,6 +3959,7 @@ "batchTools": { "title": "Alat massal", "description": "Jalankan satu alat vektor pada banyak lapisan sekaligus.", + "runBatch": "Jalankan massal", "tool": "Alat", "sharedParameters": "Parameter bersama", "noExtraParameters": "Alat ini tidak memiliki parameter tambahan.", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 84d59a9c0e..b77b234df5 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -2215,7 +2215,8 @@ "dashboard": "Dashboard", "assistant": "Assistente IA", "geocode": "Geocodifica indirizzi", - "modelBuilder": "Batch e modelli", + "batchTools": "Strumenti in blocco", + "modelBuilder": "Generatore di modelli", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "Segmentazione IA", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Strumenti in blocco", "description": "Esegui uno strumento vettoriale su molti livelli in una volta.", + "runBatch": "Esegui in blocco", "tool": "Strumento", "sharedParameters": "Parametri condivisi", "noExtraParameters": "Questo strumento non ha parametri aggiuntivi.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index f9f9dd310d..d4eaae572b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -2170,7 +2170,8 @@ "dashboard": "ダッシュボード", "assistant": "AIアシスタント", "geocode": "住所をジオコーディング", - "modelBuilder": "バッチ処理とモデル", + "batchTools": "バッチツール", + "modelBuilder": "モデルビルダー", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AIセグメンテーション", @@ -3958,6 +3959,7 @@ "batchTools": { "title": "バッチツール", "description": "1 つのベクターツールを多数のレイヤーに一括で実行します。", + "runBatch": "バッチを実行", "tool": "ツール", "sharedParameters": "共通パラメータ", "noExtraParameters": "このツールに追加のパラメータはありません。", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index bf02d61be0..c31e3ec9f9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -2215,7 +2215,8 @@ "dashboard": "დაფა", "assistant": "AI ასისტენტი", "geocode": "მისამართების გეოკოდირება", - "modelBuilder": "პაკეტები და მოდელები", + "batchTools": "სერიული ხელსაწყოები", + "modelBuilder": "მოდელის შემქმნელი", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI სეგმენტაცია", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "სერიული ხელსაწყოები", "description": "ერთი ვექტორული ხელსაწყოს გაშვება მრავალ ფენაზე ერთდროულად.", + "runBatch": "სერიის გაშვება", "tool": "ხელსაწყო", "sharedParameters": "საერთო პარამეტრები", "noExtraParameters": "ამ ხელსაწყოს დამატებითი პარამეტრები არ აქვს.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 222fbd5745..b36f8cf0c2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -2170,7 +2170,8 @@ "dashboard": "대시보드", "assistant": "AI 어시스턴트", "geocode": "주소 지오코딩", - "modelBuilder": "배치 및 모델", + "batchTools": "일괄 도구", + "modelBuilder": "모델 빌더", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI 분할", @@ -3958,6 +3959,7 @@ "batchTools": { "title": "일괄 도구", "description": "하나의 벡터 도구를 여러 레이어에 한 번에 실행합니다.", + "runBatch": "일괄 실행", "tool": "도구", "sharedParameters": "공통 매개변수", "noExtraParameters": "이 도구에는 추가 매개변수가 없습니다.", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 77e4eb8b19..8001fe87a3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -2215,7 +2215,8 @@ "dashboard": "Dashboard", "assistant": "AI-assistent", "geocode": "Adressen geocoderen", - "modelBuilder": "Batches & modellen", + "batchTools": "Batchgereedschappen", + "modelBuilder": "Modelbouwer", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI-segmentatie", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Batchgereedschappen", "description": "Eén vectorgereedschap op meerdere lagen tegelijk uitvoeren.", + "runBatch": "Batch uitvoeren", "tool": "Gereedschap", "sharedParameters": "Gedeelde parameters", "noExtraParameters": "Dit gereedschap heeft geen extra parameters.", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index c071ba8e1f..c4d61416c4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -2215,7 +2215,8 @@ "dashboard": "Painel", "assistant": "Assistente de IA", "geocode": "Geocodificar endereços", - "modelBuilder": "Lotes e modelos", + "batchTools": "Ferramentas em lote", + "modelBuilder": "Construtor de modelos", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "Segmentação por IA", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Ferramentas em lote", "description": "Executar uma ferramenta vetorial em várias camadas de uma vez.", + "runBatch": "Executar lote", "tool": "Ferramenta", "sharedParameters": "Parâmetros compartilhados", "noExtraParameters": "Esta ferramenta não tem parâmetros adicionais.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index d8c72b89ab..4532cb8b4c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -2305,7 +2305,8 @@ "dashboard": "Панель мониторинга", "assistant": "ИИ-ассистент", "geocode": "Геокодировать адреса", - "modelBuilder": "Пакеты и модели", + "batchTools": "Пакетные инструменты", + "modelBuilder": "Конструктор моделей", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "ИИ-сегментация", @@ -4159,6 +4160,7 @@ "batchTools": { "title": "Пакетные инструменты", "description": "Запустить один векторный инструмент сразу для многих слоёв.", + "runBatch": "Запустить пакет", "tool": "Инструмент", "sharedParameters": "Общие параметры", "noExtraParameters": "У этого инструмента нет дополнительных параметров.", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 579f0d9304..a3662212cd 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -2170,7 +2170,8 @@ "dashboard": "แดชบอร์ด", "assistant": "ผู้ช่วย AI", "geocode": "แปลงที่อยู่เป็นพิกัด", - "modelBuilder": "งานแบบชุดและโมเดล", + "batchTools": "เครื่องมือแบบกลุ่ม", + "modelBuilder": "ตัวสร้างแบบจำลอง", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "การแบ่งส่วนภาพด้วย AI", @@ -3958,6 +3959,7 @@ "batchTools": { "title": "เครื่องมือแบบกลุ่ม", "description": "เรียกใช้เครื่องมือเวกเตอร์เดียวกับหลายชั้นข้อมูลพร้อมกัน", + "runBatch": "เรียกใช้แบบกลุ่ม", "tool": "เครื่องมือ", "sharedParameters": "พารามิเตอร์ที่ใช้ร่วมกัน", "noExtraParameters": "เครื่องมือนี้ไม่มีพารามิเตอร์เพิ่มเติม", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index bfbe2b79df..02d875dca3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -2215,7 +2215,8 @@ "dashboard": "Gösterge Paneli", "assistant": "AI Asistanı", "geocode": "Adresleri Coğrafi Kodla", - "modelBuilder": "Toplu İşler & Modeller", + "batchTools": "Toplu araçlar", + "modelBuilder": "Model Oluşturucu", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI Segmentasyonu", @@ -4025,6 +4026,7 @@ "batchTools": { "title": "Toplu araçlar", "description": "Tek bir vektör aracını birçok katmanda aynı anda çalıştırın.", + "runBatch": "Toplu çalıştır", "tool": "Araç", "sharedParameters": "Ortak parametreler", "noExtraParameters": "Bu aracın ek parametresi yok.", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index ce07cb3105..59a30a54bb 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -2167,7 +2167,8 @@ "dashboard": "Trang tổng quan", "assistant": "Trợ lý AI", "geocode": "Mã hóa địa lý địa chỉ", - "modelBuilder": "Xử lý hàng loạt & Mô hình", + "batchTools": "Công cụ hàng loạt", + "modelBuilder": "Trình dựng mô hình", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "Phân đoạn AI", @@ -4044,6 +4045,7 @@ "batchTools": { "title": "Công cụ hàng loạt", "description": "Chạy một công cụ vector trên nhiều lớp cùng lúc.", + "runBatch": "Chạy hàng loạt", "tool": "Dụng cụ", "sharedParameters": "Thông số được chia sẻ", "noExtraParameters": "Công cụ này không có tham số bổ sung.", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index e3a6164e77..a46b8099b8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -2170,7 +2170,8 @@ "dashboard": "仪表盘", "assistant": "AI 助手", "geocode": "地理编码地址", - "modelBuilder": "批处理与模型", + "batchTools": "批处理工具", + "modelBuilder": "模型构建器", "planetaryComputer": "Planetary Computer", "earthEngine": "Earth Engine", "segmentation": "AI 分割", @@ -3958,6 +3959,7 @@ "batchTools": { "title": "批处理工具", "description": "对多个图层一次性运行同一个矢量工具。", + "runBatch": "运行批处理", "tool": "工具", "sharedParameters": "共享参数", "noExtraParameters": "此工具没有额外参数。", From 3be06dc24f292c15db5297be37eaa2c203a17bdb Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 21:44:53 -0400 Subject: [PATCH 16/22] Address review feedback and move Model Builder up a level Review comments: - graphToLinearSteps now copies the source `input` node's `layerId` into the first step's parameters. runModel only overrides a step's input parameter from step 1 onwards, so without this every canvas-authored model produced a legacy `steps` fallback that failed on its very first tool. - graphToLinearSteps now also checks the single input node's out-degree and the single output node's in-degree. A branch through a shared input/output kept every tool node at in-degree 1 / out-degree 1, so it projected as a chain and runModel silently fed one branch from the other's output. - Restored the "delete a saved model" affordance the canvas rewrite dropped: a trash button beside the saved-models picker, wired to the store's deleteModel action, enabled only when the open model is one of them. - Canvas is usable from the keyboard: node cards are focusable buttons that select on Enter/Space, and ports wire by activation (arm an output port, then activate an input port) since native button activation fires `click` and never `pointerdown`. - Node drags coalesce to one setGraph per animation frame instead of one per pointermove tick, and GraphNodeCard is memoized so a move only re-renders the card that moved rather than every card on the canvas. - Import checks `file.size` against a byte cap before reading and parsing, matching how arcgis-project-import.ts bounds its input; the node/edge caps could only fire after the whole file had been decoded. - executeModelTool's thrown messages go through t(), so the run log is no longer half-localized where they are appended to a translated prefix. - Dropped the orphaned createId JSDoc left over from the ModelBuilderDialog to BatchToolsDialog rename. Model Builder placement and canvas ergonomics: - Model Builder is now a top-level Processing menu item next to SQL Workspace and the other workspaces, rather than buried in the GeoLibre Toolbox submenu. It composes tools from every toolbox, so filing it under one misdescribed its reach. - The palette and inspector columns are draggable, with a keyboard path on each splitter and a bound tying them to the panel width so the canvas can never be squeezed away. The splitter direction follows the computed writing direction, so it works in mirrored (RTL) locales. - New Arrange button re-runs the depth-based layout over hand-placed nodes. autoLayout keeps its "only when unpositioned" guard; the new layoutGraph export is the unconditional form behind the button. Verified in a browser against the production build: Model Builder opens from the top-level menu, keyboard-only wiring creates an edge, Arrange re-lays the graph along the flow, both splitters resize by drag and by arrow key (checked in Arabic that they mirror), and saving then deleting a model logs both. --- .../layout/toolbar/ProcessingMenu.tsx | 21 +- .../processing/BatchToolsDialog.tsx | 1 - .../model-builder/ModelBuilderPanel.tsx | 389 +++++++++++++++--- .../geolibre-desktop/src/i18n/locales/ar.json | 11 +- .../geolibre-desktop/src/i18n/locales/de.json | 11 +- .../geolibre-desktop/src/i18n/locales/en.json | 11 +- .../geolibre-desktop/src/i18n/locales/es.json | 11 +- .../geolibre-desktop/src/i18n/locales/fa.json | 11 +- .../geolibre-desktop/src/i18n/locales/fr.json | 11 +- .../geolibre-desktop/src/i18n/locales/hi.json | 11 +- .../geolibre-desktop/src/i18n/locales/id.json | 11 +- .../geolibre-desktop/src/i18n/locales/it.json | 11 +- .../geolibre-desktop/src/i18n/locales/ja.json | 11 +- .../geolibre-desktop/src/i18n/locales/ka.json | 11 +- .../geolibre-desktop/src/i18n/locales/ko.json | 11 +- .../geolibre-desktop/src/i18n/locales/nl.json | 11 +- .../geolibre-desktop/src/i18n/locales/pt.json | 11 +- .../geolibre-desktop/src/i18n/locales/ru.json | 11 +- .../geolibre-desktop/src/i18n/locales/th.json | 11 +- .../geolibre-desktop/src/i18n/locales/tr.json | 11 +- .../geolibre-desktop/src/i18n/locales/vi.json | 11 +- .../geolibre-desktop/src/i18n/locales/zh.json | 11 +- .../src/lib/model-graph-edit.ts | 18 +- packages/processing/src/model-graph.ts | 29 +- tests/model-graph-edit.test.ts | 26 ++ tests/model-graph.test.ts | 35 +- 26 files changed, 638 insertions(+), 90 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx index 450bc842d8..a573a28acc 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx @@ -93,8 +93,8 @@ export function ProcessingMenu({ // Section visibility, so dividers never render with nothing on one side when a // UI profile (or mobile) hides whole sections. `showGeolibreTools` are the - // client tool submenus; `showGeolibreActions` are geocode/model-builder/ - // segmentation below the in-submenu divider. + // client tool submenus; `showGeolibreActions` are geocode/batch/segmentation + // below the in-submenu divider. const showGeolibreTools = (!mobile && show("processing.conversion")) || show("processing.vector") || @@ -104,12 +104,12 @@ export function ProcessingMenu({ const showGeolibreActions = show("processing.geocode") || show("processing.batchTools") || - show("processing.modelBuilder") || (!mobile && show("processing.segmentation")) || show("processing.objectDetection") || show("processing.segmentEverything"); const showGeolibre = showGeolibreTools || showGeolibreActions; const showWorkspacesOrServices = + show("processing.modelBuilder") || show("processing.history") || show("processing.sqlWorkspace") || show("processing.pythonConsole") || @@ -505,11 +505,6 @@ export function ProcessingMenu({ {t("toolbar.item.batchTools")} )} - {show("processing.modelBuilder") && ( - setModelBuilderOpen(true)}> - {t("toolbar.item.modelBuilder")} - - )} {!mobile && show("processing.segmentation") && ( setSegmentationOpen(true)}> {t("toolbar.command.segmentation")} @@ -535,6 +530,16 @@ export function ProcessingMenu({ {/* Divide the tool-category submenus (Whitebox, GeoLibre) from the workspaces and consoles below. Only when both sides are present. */} {(showWhitebox || showGeolibre) && showWorkspacesOrServices && } + {/* Model Builder sits at the top level rather than inside the GeoLibre + Toolbox submenu: it is a canvas that composes tools from every + toolbox (Whitebox raster and GeoLibre vector alike), so filing it + under one of them would misdescribe its reach. It heads the + workspaces block with its SQL/Python/notebook/dashboard siblings. */} + {show("processing.modelBuilder") && ( + setModelBuilderOpen(true)}> + {t("toolbar.item.modelBuilder")} + + )} {show("processing.sqlWorkspace") && ( setSqlWorkspaceOpen(true)}> {t("toolbar.command.sqlWorkspace")} diff --git a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx index 915cba6600..bc33f23970 100644 --- a/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx +++ b/apps/geolibre-desktop/src/components/processing/BatchToolsDialog.tsx @@ -35,7 +35,6 @@ const PRIMARY_INPUT_PARAM = "layer"; /** Sample size when scanning a layer's attribute field names. */ const FIELD_SCAN_SAMPLE = 1000; -/** A best-effort unique id (webview always has crypto.randomUUID). */ /** Vector tools grouped by their `group` label, preserving registry order. */ function groupedTools(): { group: string; tools: ProcessingAlgorithm[] }[] { const groups: { group: string; tools: ProcessingAlgorithm[] }[] = []; diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 1502d36c58..ecf951f036 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -28,8 +28,20 @@ import { type WhiteboxTool, } from "@geolibre/processing"; import { Button, Input, Label, ScrollArea, Select, cn } from "@geolibre/ui"; -import { Download, GripVertical, Loader2, Play, Plus, Save, Trash2, Upload, X } from "lucide-react"; import { + Download, + GripVertical, + LayoutGrid, + Loader2, + Play, + Plus, + Save, + Trash2, + Upload, + X, +} from "lucide-react"; +import { + memo, useCallback, useEffect, useLayoutEffect, @@ -38,6 +50,7 @@ import { useState, type CSSProperties, type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactElement, } from "react"; @@ -58,6 +71,7 @@ import { autoLayout, connectNodes, emptyModelGraph, + layoutGraph, moveNode, removeEdge, removeNode, @@ -74,9 +88,30 @@ const TOOL_DRAG_TYPE = "application/x-geolibre-model-tool"; /** Identifies an exported Model Builder file, so a stray JSON is rejected. */ const MODEL_SCHEMA = "https://geolibre.app/schemas/model-graph-v1.json"; const MODEL_VERSION = "1.0.0"; +/** Bounds on the draggable palette and inspector columns, in pixels. */ +const MIN_SIDE_WIDTH = 140; +const MAX_SIDE_WIDTH = 480; +const DEFAULT_PALETTE_WIDTH = 208; +const DEFAULT_INSPECTOR_WIDTH = 224; +/** + * Share of the panel a single side column may take. Both columns are + * `shrink-0`, so without this two columns dragged wide (or a panel later + * resized narrow) would squeeze the canvas between them to nothing with no way + * left to grab a node and drag the column back. + */ +const MAX_SIDE_FRACTION = 0.4; + /** Sanity bounds on an imported file; a hand-built model is nowhere near these. */ const MAX_IMPORT_NODES = 2000; const MAX_IMPORT_EDGES = 4000; +/** + * Byte cap checked before the file is read, mirroring how the other importers + * in this app bound size (`MAX_CIM_BYTES` in `arcgis-project-import.ts`). The + * node/edge caps above can only fire once the whole file has been decoded and + * parsed, so a pathological file would be fully loaded before anything could + * reject it. A model at the node/edge caps serializes well under this. + */ +const MAX_IMPORT_BYTES = 16 * 1024 * 1024; const MIN_WIDTH = 820; const MIN_HEIGHT = 420; @@ -122,10 +157,13 @@ export function ModelBuilderPanel({ const layers = useAppStore((s) => s.layers); const savedModels = useAppStore((s) => s.models); const saveModel = useAppStore((s) => s.saveModel); + const deleteModel = useAppStore((s) => s.deleteModel); const addGeoJsonLayer = useAppStore((s) => s.addGeoJsonLayer); const [position, setPosition] = useState({ x: 48, y: 48 }); const [size, setSize] = useState({ width: 980, height: 560 }); + const [paletteWidth, setPaletteWidth] = useState(DEFAULT_PALETTE_WIDTH); + const [inspectorWidth, setInspectorWidth] = useState(DEFAULT_INSPECTOR_WIDTH); const [modelId, setModelId] = useState(() => createId()); const [modelName, setModelName] = useState(""); const [graph, setGraph] = useState(emptyModelGraph); @@ -139,6 +177,12 @@ export function ModelBuilderPanel({ const [log, setLog] = useState([]); const [running, setRunning] = useState(false); const [nodeStatus, setNodeStatus] = useState>({}); + /** + * The output port a keyboard/click user has armed, waiting for an input port + * to complete the connection. Null during pointer drags, which carry their + * own in-flight state in {@link linking}. + */ + const [armedPort, setArmedPort] = useState<{ nodeId: string; portId: string } | null>(null); const abortRef = useRef(null); const sectionRef = useRef(null); const canvasRef = useRef(null); @@ -256,6 +300,9 @@ export function ModelBuilderPanel({ const resetRunState = useCallback(() => { abortRun(); + // A port armed against the outgoing graph would wire the wrong node once a + // different model is loaded under it. + setArmedPort(null); // abortRun() clears the ref, so the in-flight run's `finally` no longer // matches its own controller and will not clear this itself. setRunning(false); @@ -282,6 +329,23 @@ export function ModelBuilderPanel({ [resetRunState], ); + /** + * Forget the loaded model. The picker only offers models the project already + * holds, so the button is meaningful exactly when the open model is one of + * them; deleting leaves the canvas as-is so an accidental click loses only + * the saved copy, which Save writes straight back. + */ + const handleDeleteModel = useCallback(() => { + if (!savedModels.some((model) => model.id === modelId)) return; + deleteModel(modelId); + appendLog(t("processing.modelBuilder.deletedLog")); + }, [savedModels, deleteModel, modelId, appendLog, t]); + + /** Re-run the depth-based layout over the nodes the user has moved around. */ + const handleArrange = useCallback(() => { + setGraph((current) => layoutGraph(current)); + }, []); + const handleSave = useCallback(() => { // Also write the legacy linear projection when the graph happens to be a // single vector chain, so a build without the canvas can still run it. @@ -321,6 +385,11 @@ export function ModelBuilderPanel({ const handleImport = useCallback( async (file: File) => { try { + // Bounded before the read, so an obviously-too-large file never gets + // decoded and parsed in full just to be rejected afterwards. + if (file.size > MAX_IMPORT_BYTES) { + throw new Error(t("processing.modelBuilder.importTooLarge")); + } const parsed = JSON.parse(await file.text()) as { $schema?: unknown; version?: unknown; @@ -426,13 +495,24 @@ export function ModelBuilderPanel({ const startX = event.clientX; const startY = event.clientY; const origin = { x: node.x, y: node.y }; + // A pointer can report several moves per frame, and each setGraph here + // re-runs validateModelGraph over the whole graph and repaints the edge + // layer. Coalescing to one commit per animation frame keeps that work at + // display rate no matter how fast the device samples. + let frame = 0; + let pending: { x: number; y: number } | null = null; + const commit = () => { + frame = 0; + const next = pending; + pending = null; + if (next) setGraph((current) => moveNode(current, node.id, next)); + }; const handleMove = (move: PointerEvent) => { - setGraph((current) => - moveNode(current, node.id, { - x: Math.max(0, origin.x + (move.clientX - startX)), - y: Math.max(0, origin.y + (move.clientY - startY)), - }), - ); + pending = { + x: Math.max(0, origin.x + (move.clientX - startX)), + y: Math.max(0, origin.y + (move.clientY - startY)), + }; + if (!frame) frame = requestAnimationFrame(commit); }; const handleEnd = () => { if (handle.hasPointerCapture(event.pointerId)) @@ -440,6 +520,10 @@ export function ModelBuilderPanel({ handle.removeEventListener("pointermove", handleMove); handle.removeEventListener("pointerup", handleEnd); handle.removeEventListener("pointercancel", handleEnd); + // Land the last sampled position before settling, or a move that was + // still waiting on its frame would be dropped on release. + if (frame) cancelAnimationFrame(frame); + commit(); // Settle on drop so a card never comes to rest covering another's // ports, which would make those ports unclickable with no way back. setGraph((current) => settleNode(current, node.id)); @@ -459,6 +543,54 @@ export function ModelBuilderPanel({ y: number; } | null>(null); + /** + * Connect two ports, reporting a refusal the way the pointer path does. + * Shared by the pointer drop and the keyboard click path so both wire nodes + * through exactly the same rules. + */ + const connectPorts = useCallback( + (from: { nodeId: string; portId: string }, to: { nodeId: string; portId: string }) => { + setGraph((current) => { + const result = connectNodes(current, from, to, createId); + if ("rejected" in result) { + appendLog( + result.rejected === "cycle" + ? t("processing.modelBuilder.connectCycle") + : t("processing.modelBuilder.connectSameNode"), + ); + return current; + } + return result.graph; + }); + }, + [appendLog, t], + ); + + /** + * Keyboard/click wiring: activating an output port arms it, activating an + * input port completes the connection. Native button activation (Enter or + * Space) fires `click`, never `pointerdown`, so without this the whole + * canvas is unusable without a pointing device. Activating the armed port + * again disarms it, so there is a way out that does not need the mouse. + */ + const handlePortActivate = useCallback( + (side: "in" | "out", nodeId: string, portId: string) => { + if (side === "out") { + setArmedPort((current) => + current && current.nodeId === nodeId && current.portId === portId + ? null + : { nodeId, portId }, + ); + return; + } + setArmedPort((current) => { + if (current) connectPorts(current, { nodeId, portId }); + return null; + }); + }, + [connectPorts], + ); + const handlePortPointerDown = useCallback( (event: ReactPointerEvent, nodeId: string, portId: string) => { event.preventDefault(); @@ -483,32 +615,14 @@ export function ModelBuilderPanel({ ?.closest("[data-port='in']"); const toNode = dropped?.dataset.nodeId; const toPort = dropped?.dataset.portId; - if (toNode && toPort) { - setGraph((current) => { - const result = connectNodes( - current, - { nodeId, portId }, - { nodeId: toNode, portId: toPort }, - createId, - ); - if ("rejected" in result) { - appendLog( - result.rejected === "cycle" - ? t("processing.modelBuilder.connectCycle") - : t("processing.modelBuilder.connectSameNode"), - ); - return current; - } - return result.graph; - }); - } + if (toNode && toPort) connectPorts({ nodeId, portId }, { nodeId: toNode, portId: toPort }); setLinking(null); }; handle.addEventListener("pointermove", handleMove); handle.addEventListener("pointerup", handleEnd); handle.addEventListener("pointercancel", handleEnd); }, - [canvasPoint, appendLog, t], + [canvasPoint, connectPorts], ); // --- Running ------------------------------------------------------------ @@ -574,6 +688,7 @@ export function ModelBuilderPanel({ layers, duckdb, log: appendLog, + t, }), }); // Wait for the host's raster adds before summarising: they settle after @@ -629,6 +744,66 @@ export function ModelBuilderPanel({ handle.addEventListener("pointercancel", handleEnd); }; + /** + * Drag one of the two column splitters. + * + * The palette grows as the pointer moves towards the canvas and the + * inspector grows as it moves away from it, which in a mirrored (RTL) layout + * is the opposite screen direction — hence the sign taken from the element's + * computed `direction` rather than assuming left-to-right. + */ + /** Upper bound for one side column at the panel's current width. */ + const maxSideWidth = Math.max( + MIN_SIDE_WIDTH, + Math.min(MAX_SIDE_WIDTH, size.width * MAX_SIDE_FRACTION), + ); + + const handleSideResizeStart = useCallback( + (event: ReactPointerEvent, side: "palette" | "inspector") => { + event.preventDefault(); + event.stopPropagation(); + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const dirSign = getComputedStyle(handle).direction === "rtl" ? -1 : 1; + const sideSign = side === "palette" ? 1 : -1; + const startX = event.clientX; + const start = side === "palette" ? paletteWidth : inspectorWidth; + const setWidth = side === "palette" ? setPaletteWidth : setInspectorWidth; + const handleMove = (move: PointerEvent) => { + setWidth( + clamp(start + (move.clientX - startX) * dirSign * sideSign, MIN_SIDE_WIDTH, maxSideWidth), + ); + }; + const handleEnd = () => { + if (handle.hasPointerCapture(event.pointerId)) + handle.releasePointerCapture(event.pointerId); + handle.removeEventListener("pointermove", handleMove); + handle.removeEventListener("pointerup", handleEnd); + handle.removeEventListener("pointercancel", handleEnd); + }; + handle.addEventListener("pointermove", handleMove); + handle.addEventListener("pointerup", handleEnd); + handle.addEventListener("pointercancel", handleEnd); + }, + [paletteWidth, inspectorWidth, maxSideWidth], + ); + + /** Keyboard path for the splitters, so a column is resizable without a mouse. */ + const handleSideResizeKey = useCallback( + (event: ReactKeyboardEvent, side: "palette" | "inspector") => { + const step = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0; + if (!step) return; + event.preventDefault(); + const dirSign = getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1; + const sideSign = side === "palette" ? 1 : -1; + const setWidth = side === "palette" ? setPaletteWidth : setInspectorWidth; + setWidth((current) => + clamp(current + step * dirSign * sideSign, MIN_SIDE_WIDTH, maxSideWidth), + ); + }, + [maxSideWidth], + ); + const handleResizeStart = (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -712,6 +887,16 @@ export function ModelBuilderPanel({ + +
)}
@@ -1125,16 +1351,26 @@ function portsOf( return { inputs: descriptor?.inputs ?? [], outputs: descriptor?.outputs ?? [] }; } -/** One draggable card on the canvas. */ -function GraphNodeCard({ +/** + * One draggable card on the canvas. + * + * Memoized because a node drag commits a new graph object on every animation + * frame: without this, every card on the canvas re-renders for a move that + * only changed one of them. Its handler props are all `useCallback`-stable, so + * only the moved card's `node` identity actually changes. + */ +const GraphNodeCard = memo(function GraphNodeCard({ node, descriptor, layers, selected, status, hasIssue, + armedPortId, + onSelect, onPointerDown, onPortPointerDown, + onPortActivate, }: { node: ModelGraphNode; descriptor: ModelToolDescriptor | undefined; @@ -1142,12 +1378,16 @@ function GraphNodeCard({ selected: boolean; status?: "running" | "done" | "error"; hasIssue: boolean; + /** The output port on this node armed for a keyboard connection, if any. */ + armedPortId?: string; + onSelect: (nodeId: string) => void; onPointerDown: (event: ReactPointerEvent, node: ModelGraphNode) => void; onPortPointerDown: ( event: ReactPointerEvent, nodeId: string, portId: string, ) => void; + onPortActivate: (side: "in" | "out", nodeId: string, portId: string) => void; }): ReactElement { const { t } = useTranslation(); const ports = portsOf(node, descriptor); @@ -1160,8 +1400,26 @@ function GraphNodeCard({ : (descriptor?.name ?? node.toolId ?? ""); return ( + // Focusable with a role, so the card can be reached and selected from the + // keyboard; without it selecting a node (and so editing its parameters in + // the inspector) needed a pointer. Dragging stays pointer-only — a card's + // position is presentation, not part of the model.
onPointerDown(event, node)} + onKeyDown={(event) => { + // Only the card's own activation. A keydown on one of the port buttons + // bubbles up here, and preventDefault() on that would stop the browser + // synthesizing the port's `click` — which is the whole keyboard wiring + // path. + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onSelect(node.id); + }} style={{ left: node.x, top: node.y, width: NODE_WIDTH, height: NODE_HEIGHT }} className={cn( "absolute cursor-grab select-none rounded-md border bg-card p-2 shadow-sm active:cursor-grabbing", @@ -1194,8 +1452,9 @@ function GraphNodeCard({ data-port-id={port.id} title={portLabel(t, port.label)} aria-label={t("processing.modelBuilder.inputPort", { port: portLabel(t, port.label) })} + onClick={() => onPortActivate("in", node.id, port.id)} style={{ left: -6, top: at.y - node.y - 5 }} - className="absolute h-2.5 w-2.5 rounded-full border border-primary bg-background" + className="absolute h-2.5 w-2.5 cursor-pointer rounded-full border border-primary bg-background" /> ); })} @@ -1210,15 +1469,27 @@ function GraphNodeCard({ data-port-id={port.id} title={portLabel(t, port.label)} aria-label={t("processing.modelBuilder.outputPort", { port: portLabel(t, port.label) })} + aria-pressed={armedPortId === port.id} + // Pointer users drag; keyboard users activate, which fires `click` + // and never `pointerdown`. Both paths end in connectPorts. onPointerDown={(event) => onPortPointerDown(event, node.id, port.id)} + onClick={(event) => { + // A pointer drag ends in its own `pointerup` handler and then + // fires a click here too, which would arm the port it just wired. + if (event.detail !== 0) return; + onPortActivate("out", node.id, port.id); + }} style={{ right: -6, top: at.y - node.y - 5 }} - className="absolute h-2.5 w-2.5 cursor-crosshair rounded-full border border-primary bg-primary" + className={cn( + "absolute h-2.5 w-2.5 cursor-crosshair rounded-full border border-primary bg-primary", + armedPortId === port.id && "ring-2 ring-primary ring-offset-1", + )} /> ); })}
); -} +}); /** Right-hand properties panel for whichever node is selected. */ function NodeInspector({ @@ -1416,6 +1687,10 @@ async function layerToModelValue( * `layer_inputs` — GeoJSON for a `vector_in`, raw GeoTIFF bytes for a * `raster_in` — and their job outputs are mapped back onto the descriptor's * output ports so the next node receives the right payload. + * + * Takes `t` because everything it throws is surfaced verbatim in the run log, + * appended to an already-translated prefix; an English literal here would + * leave that line half-localized in all 19 locales. */ async function executeModelTool({ node, @@ -1425,6 +1700,7 @@ async function executeModelTool({ layers, duckdb, log, + t, }: { node: ModelGraphNode; descriptor: ModelToolDescriptor; @@ -1433,17 +1709,21 @@ async function executeModelTool({ layers: GeoLibreLayer[]; duckdb: ReturnType; log: (message: string) => void; + t: TFunction; }): Promise> { if (descriptor.provider === "vector") { const tool = getVectorTool(descriptor.toolId); - if (!tool) throw new Error(`Unknown vector tool "${descriptor.toolId}"`); + if (!tool) + throw new Error(t("processing.modelBuilder.issueUnknownTool", { tool: descriptor.toolId })); // Each wired input becomes a synthetic layer the tool resolves by id, the // same trick the linear runner uses to chain a step's output forward. const synthetic: GeoLibreLayer[] = []; const parameters = { ...(node.parameters ?? {}) }; for (const [portId, value] of Object.entries(inputs)) { if (value.kind !== "vector") { - throw new Error(`"${portId}" needs vector data, but a raster arrived.`); + throw new Error( + t("processing.modelBuilder.portNeedsVector", { port: portLabel(t, portId) }), + ); } const syntheticId = `__geolibre_model_${node.id}_${portId}`; synthetic.push(syntheticLayer(syntheticId, portId, value.geojson)); @@ -1455,7 +1735,8 @@ async function executeModelTool({ duckdb, signal, }); - if (!output) throw new Error(`"${descriptor.name}" produced no output.`); + if (!output) + throw new Error(t("processing.modelBuilder.toolNoOutput", { tool: descriptor.name })); return { out: { kind: "vector", geojson: output } }; } @@ -1497,7 +1778,7 @@ async function executeModelTool({ } } if (Object.keys(results).length === 0) { - throw new Error(`"${descriptor.name}" produced no usable output.`); + throw new Error(t("processing.modelBuilder.toolNoUsableOutput", { tool: descriptor.name })); } return results; } diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 371f282ad6..d9262eb130 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -4318,6 +4318,8 @@ "modelNamePlaceholder": "نموذج بلا عنوان", "untitledModel": "نموذج بلا عنوان", "newModel": "جديد", + "arrange": "ترتيب", + "arrangeHint": "ترتيب العقد على امتداد مسار التدفق", "runModel": "تشغيل", "cancelRun": "إلغاء", "runCancelled": "تم إلغاء التشغيل.", @@ -4339,6 +4341,8 @@ "exportModel": "تصدير", "savedModels": "النماذج المحفوظة", "loadModelPlaceholder": "تحميل نموذج محفوظ...", + "deleteModel": "حذف", + "deletedLog": "تم حذف النموذج من المشروع.", "searchTools": "البحث عن الأدوات", "loadingTools": "جارٍ تحميل الأدوات...", "noToolsMatch": "لا توجد أدوات تطابق بحثك.", @@ -4352,6 +4356,8 @@ "removeConnection": "إزالة الاتصال", "removeNode": "إزالة العقدة", "resizePanel": "تغيير حجم اللوحة", + "resizePalette": "تغيير حجم لوحة الأدوات", + "resizeInspector": "تغيير حجم لوحة الخصائص", "selectNodeHint": "اختر عقدة لتحرير إعداداتها.", "sourceLayer": "طبقة المصدر", "chooseLayer": "اختر طبقة...", @@ -4370,7 +4376,10 @@ "importFailed": "فشل الاستيراد", "importInvalid": "هذا الملف لا يحتوي على مخطط نموذج.", "importUnsupported": "هذا الملف ليس نموذج GeoLibre.", - "rasterOutputUnsupported": "«{{name}}» نتيجة راستر لا يمكن لهذه النسخة إضافتها إلى الخريطة." + "rasterOutputUnsupported": "«{{name}}» نتيجة راستر لا يمكن لهذه النسخة إضافتها إلى الخريطة.", + "portNeedsVector": "«{{port}}» يحتاج إلى بيانات متجهة، لكن وصلت بيانات راستر.", + "toolNoOutput": "«{{tool}}» لم تُنتج أي مخرجات.", + "toolNoUsableOutput": "«{{tool}}» لم تُنتج مخرجات قابلة للاستخدام." }, "parameterField": { "selectLayer": "حدد طبقة...", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index e5c6b2d3ce..54d4d08647 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Unbenanntes Modell", "untitledModel": "Unbenanntes Modell", "newModel": "Neu", + "arrange": "Anordnen", + "arrangeHint": "Die Knoten entlang des Ablaufs anordnen", "runModel": "Ausführen", "cancelRun": "Abbrechen", "runCancelled": "Ausführung abgebrochen.", @@ -4072,6 +4074,8 @@ "exportModel": "Exportieren", "savedModels": "Gespeicherte Modelle", "loadModelPlaceholder": "Gespeichertes Modell laden ...", + "deleteModel": "Löschen", + "deletedLog": "Modell aus dem Projekt gelöscht.", "searchTools": "Werkzeuge suchen", "loadingTools": "Werkzeuge werden geladen ...", "noToolsMatch": "Keine Werkzeuge entsprechen Ihrer Suche.", @@ -4085,6 +4089,8 @@ "removeConnection": "Verbindung entfernen", "removeNode": "Knoten entfernen", "resizePanel": "Bereichsgröße ändern", + "resizePalette": "Werkzeugpalette in der Größe ändern", + "resizeInspector": "Eigenschaftenbereich in der Größe ändern", "selectNodeHint": "Wählen Sie einen Knoten, um seine Einstellungen zu bearbeiten.", "sourceLayer": "Quellebene", "chooseLayer": "Ebene wählen ...", @@ -4103,7 +4109,10 @@ "importFailed": "Import fehlgeschlagen", "importInvalid": "Diese Datei enthält kein Modelldiagramm.", "importUnsupported": "Diese Datei ist kein GeoLibre-Modell.", - "rasterOutputUnsupported": "„{{name}}“ ist ein Rasterergebnis, das dieser Build nicht zur Karte hinzufügen kann." + "rasterOutputUnsupported": "„{{name}}“ ist ein Rasterergebnis, das dieser Build nicht zur Karte hinzufügen kann.", + "portNeedsVector": "„{{port}}“ benötigt Vektordaten, es kam aber ein Raster an.", + "toolNoOutput": "„{{tool}}“ hat keine Ausgabe erzeugt.", + "toolNoUsableOutput": "„{{tool}}“ hat keine verwendbare Ausgabe erzeugt." }, "parameterField": { "selectLayer": "Ebene auswählen …", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index b33e9f27a3..dd4381ed3c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4061,6 +4061,8 @@ "modelNamePlaceholder": "Untitled model", "untitledModel": "Untitled model", "newModel": "New", + "arrange": "Arrange", + "arrangeHint": "Lay the nodes out along the flow", "runModel": "Run", "cancelRun": "Cancel", "runCancelled": "Run cancelled.", @@ -4082,6 +4084,8 @@ "exportModel": "Export", "savedModels": "Saved models", "loadModelPlaceholder": "Load a saved model...", + "deleteModel": "Delete", + "deletedLog": "Model deleted from the project.", "searchTools": "Search tools", "loadingTools": "Loading tools...", "noToolsMatch": "No tools match your search.", @@ -4095,6 +4099,8 @@ "removeConnection": "Remove connection", "removeNode": "Remove node", "resizePanel": "Resize panel", + "resizePalette": "Resize the tool palette", + "resizeInspector": "Resize the properties panel", "selectNodeHint": "Select a node to edit its settings.", "sourceLayer": "Source layer", "chooseLayer": "Choose a layer...", @@ -4113,7 +4119,10 @@ "importFailed": "Import failed", "importInvalid": "That file does not contain a model graph.", "importUnsupported": "That file is not a GeoLibre model.", - "rasterOutputUnsupported": "\"{{name}}\" is a raster result, which this build cannot add to the map." + "rasterOutputUnsupported": "\"{{name}}\" is a raster result, which this build cannot add to the map.", + "portNeedsVector": "\"{{port}}\" needs vector data, but a raster arrived.", + "toolNoOutput": "\"{{tool}}\" produced no output.", + "toolNoUsableOutput": "\"{{tool}}\" produced no usable output." }, "parameterField": { "selectLayer": "Select a layer...", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 7c0ef2630e..e3cbb671b8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Modelo sin título", "untitledModel": "Modelo sin título", "newModel": "Nuevo", + "arrange": "Organizar", + "arrangeHint": "Distribuir los nodos siguiendo el flujo", "runModel": "Ejecutar", "cancelRun": "Cancelar", "runCancelled": "Ejecución cancelada.", @@ -4072,6 +4074,8 @@ "exportModel": "Exportar", "savedModels": "Modelos guardados", "loadModelPlaceholder": "Cargar un modelo guardado...", + "deleteModel": "Eliminar", + "deletedLog": "Modelo eliminado del proyecto.", "searchTools": "Buscar herramientas", "loadingTools": "Cargando herramientas...", "noToolsMatch": "Ninguna herramienta coincide con su búsqueda.", @@ -4085,6 +4089,8 @@ "removeConnection": "Quitar conexión", "removeNode": "Quitar nodo", "resizePanel": "Cambiar el tamaño del panel", + "resizePalette": "Cambiar el tamaño de la paleta de herramientas", + "resizeInspector": "Cambiar el tamaño del panel de propiedades", "selectNodeHint": "Seleccione un nodo para editar su configuración.", "sourceLayer": "Capa de origen", "chooseLayer": "Elija una capa...", @@ -4103,7 +4109,10 @@ "importFailed": "La importación falló", "importInvalid": "Ese archivo no contiene un grafo de modelo.", "importUnsupported": "Ese archivo no es un modelo de GeoLibre.", - "rasterOutputUnsupported": "«{{name}}» es un resultado ráster que esta versión no puede añadir al mapa." + "rasterOutputUnsupported": "«{{name}}» es un resultado ráster que esta versión no puede añadir al mapa.", + "portNeedsVector": "«{{port}}» necesita datos vectoriales, pero llegó un ráster.", + "toolNoOutput": "«{{tool}}» no produjo ningún resultado.", + "toolNoUsableOutput": "«{{tool}}» no produjo ningún resultado utilizable." }, "parameterField": { "selectLayer": "Seleccionar una capa...", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 11ec74428c..75a794ab1a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "مدل بدون عنوان", "untitledModel": "مدل بدون عنوان", "newModel": "جدید", + "arrange": "چیدمان", + "arrangeHint": "چیدن گره‌ها در امتداد جریان", "runModel": "اجرا", "cancelRun": "لغو", "runCancelled": "اجرا لغو شد.", @@ -4072,6 +4074,8 @@ "exportModel": "برون‌ریزی", "savedModels": "مدل‌های ذخیره‌شده", "loadModelPlaceholder": "بارگذاری یک مدل ذخیره‌شده...", + "deleteModel": "حذف", + "deletedLog": "مدل از پروژه حذف شد.", "searchTools": "جست‌وجوی ابزارها", "loadingTools": "در حال بارگذاری ابزارها...", "noToolsMatch": "هیچ ابزاری با جست‌وجوی شما مطابقت ندارد.", @@ -4085,6 +4089,8 @@ "removeConnection": "حذف اتصال", "removeNode": "حذف گره", "resizePanel": "تغییر اندازهٔ پنل", + "resizePalette": "تغییر اندازهٔ پالت ابزارها", + "resizeInspector": "تغییر اندازهٔ پنل ویژگی‌ها", "selectNodeHint": "برای ویرایش تنظیمات، یک گره را انتخاب کنید.", "sourceLayer": "لایهٔ مبدأ", "chooseLayer": "یک لایه انتخاب کنید...", @@ -4103,7 +4109,10 @@ "importFailed": "درون‌ریزی ناموفق بود", "importInvalid": "این پرونده شامل گراف مدل نیست.", "importUnsupported": "این پرونده یک مدل GeoLibre نیست.", - "rasterOutputUnsupported": "«{{name}}» یک نتیجهٔ رستری است که این نسخه نمی‌تواند به نقشه بیفزاید." + "rasterOutputUnsupported": "«{{name}}» یک نتیجهٔ رستری است که این نسخه نمی‌تواند به نقشه بیفزاید.", + "portNeedsVector": "«{{port}}» به دادهٔ برداری نیاز دارد، اما یک رستر دریافت شد.", + "toolNoOutput": "«{{tool}}» هیچ خروجی تولید نکرد.", + "toolNoUsableOutput": "«{{tool}}» خروجی قابل‌استفاده‌ای تولید نکرد." }, "parameterField": { "selectLayer": "یک لایه برگزینید...", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 8ef6addbde..876d19d303 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Modèle sans titre", "untitledModel": "Modèle sans titre", "newModel": "Nouveau", + "arrange": "Organiser", + "arrangeHint": "Disposer les nœuds le long du flux", "runModel": "Exécuter", "cancelRun": "Annuler", "runCancelled": "Exécution annulée.", @@ -4072,6 +4074,8 @@ "exportModel": "Exporter", "savedModels": "Modèles enregistrés", "loadModelPlaceholder": "Charger un modèle enregistré...", + "deleteModel": "Supprimer", + "deletedLog": "Modèle supprimé du projet.", "searchTools": "Rechercher des outils", "loadingTools": "Chargement des outils...", "noToolsMatch": "Aucun outil ne correspond à votre recherche.", @@ -4085,6 +4089,8 @@ "removeConnection": "Supprimer la connexion", "removeNode": "Supprimer le nœud", "resizePanel": "Redimensionner le panneau", + "resizePalette": "Redimensionner la palette d'outils", + "resizeInspector": "Redimensionner le panneau des propriétés", "selectNodeHint": "Sélectionnez un nœud pour modifier ses paramètres.", "sourceLayer": "Couche source", "chooseLayer": "Choisir une couche...", @@ -4103,7 +4109,10 @@ "importFailed": "Échec de l'importation", "importInvalid": "Ce fichier ne contient pas de graphe de modèle.", "importUnsupported": "Ce fichier n'est pas un modèle GeoLibre.", - "rasterOutputUnsupported": "« {{name}} » est un résultat raster que cette version ne peut pas ajouter à la carte." + "rasterOutputUnsupported": "« {{name}} » est un résultat raster que cette version ne peut pas ajouter à la carte.", + "portNeedsVector": "« {{port}} » nécessite des données vectorielles, mais un raster est arrivé.", + "toolNoOutput": "« {{tool}} » n'a produit aucun résultat.", + "toolNoUsableOutput": "« {{tool}} » n'a produit aucun résultat exploitable." }, "parameterField": { "selectLayer": "Sélectionner une couche...", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 904331a060..1c60e3fbb1 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "बिना शीर्षक मॉडल", "untitledModel": "बिना शीर्षक मॉडल", "newModel": "नया", + "arrange": "व्यवस्थित करें", + "arrangeHint": "नोड्स को प्रवाह के अनुसार व्यवस्थित करें", "runModel": "चलाएँ", "cancelRun": "रद्द करें", "runCancelled": "चलना रद्द किया गया।", @@ -4072,6 +4074,8 @@ "exportModel": "निर्यात", "savedModels": "सहेजे गए मॉडल", "loadModelPlaceholder": "सहेजा गया मॉडल लोड करें...", + "deleteModel": "हटाएँ", + "deletedLog": "मॉडल परियोजना से हटाया गया।", "searchTools": "उपकरण खोजें", "loadingTools": "उपकरण लोड हो रहे हैं...", "noToolsMatch": "आपकी खोज से कोई उपकरण मेल नहीं खाता।", @@ -4085,6 +4089,8 @@ "removeConnection": "कनेक्शन हटाएँ", "removeNode": "नोड हटाएँ", "resizePanel": "पैनल का आकार बदलें", + "resizePalette": "उपकरण पैलेट का आकार बदलें", + "resizeInspector": "गुण पैनल का आकार बदलें", "selectNodeHint": "सेटिंग्स संपादित करने के लिए कोई नोड चुनें।", "sourceLayer": "स्रोत परत", "chooseLayer": "एक परत चुनें...", @@ -4103,7 +4109,10 @@ "importFailed": "आयात विफल रहा", "importInvalid": "उस फ़ाइल में मॉडल ग्राफ़ नहीं है।", "importUnsupported": "वह फ़ाइल GeoLibre मॉडल नहीं है।", - "rasterOutputUnsupported": "\"{{name}}\" एक रास्टर परिणाम है, जिसे यह बिल्ड मानचित्र में नहीं जोड़ सकता।" + "rasterOutputUnsupported": "\"{{name}}\" एक रास्टर परिणाम है, जिसे यह बिल्ड मानचित्र में नहीं जोड़ सकता।", + "portNeedsVector": "\"{{port}}\" को वेक्टर डेटा चाहिए, लेकिन रास्टर मिला।", + "toolNoOutput": "\"{{tool}}\" ने कोई आउटपुट नहीं बनाया।", + "toolNoUsableOutput": "\"{{tool}}\" ने कोई उपयोगी आउटपुट नहीं बनाया।" }, "parameterField": { "selectLayer": "एक लेयर चुनें...", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 261b1d993d..4c887d1c58 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -3984,6 +3984,8 @@ "modelNamePlaceholder": "Model tanpa judul", "untitledModel": "Model tanpa judul", "newModel": "Baru", + "arrange": "Tata", + "arrangeHint": "Menata simpul mengikuti alur", "runModel": "Jalankan", "cancelRun": "Batal", "runCancelled": "Eksekusi dibatalkan.", @@ -4005,6 +4007,8 @@ "exportModel": "Ekspor", "savedModels": "Model tersimpan", "loadModelPlaceholder": "Muat model tersimpan...", + "deleteModel": "Hapus", + "deletedLog": "Model dihapus dari proyek.", "searchTools": "Cari alat", "loadingTools": "Memuat alat...", "noToolsMatch": "Tidak ada alat yang cocok dengan pencarian Anda.", @@ -4018,6 +4022,8 @@ "removeConnection": "Hapus koneksi", "removeNode": "Hapus simpul", "resizePanel": "Ubah ukuran panel", + "resizePalette": "Ubah ukuran palet alat", + "resizeInspector": "Ubah ukuran panel properti", "selectNodeHint": "Pilih sebuah simpul untuk mengubah pengaturannya.", "sourceLayer": "Lapisan sumber", "chooseLayer": "Pilih lapisan...", @@ -4036,7 +4042,10 @@ "importFailed": "Gagal mengimpor", "importInvalid": "Berkas itu tidak berisi graf model.", "importUnsupported": "Berkas itu bukan model GeoLibre.", - "rasterOutputUnsupported": "\"{{name}}\" adalah hasil raster yang tidak dapat ditambahkan ke peta oleh versi ini." + "rasterOutputUnsupported": "\"{{name}}\" adalah hasil raster yang tidak dapat ditambahkan ke peta oleh versi ini.", + "portNeedsVector": "\"{{port}}\" memerlukan data vektor, tetapi yang datang adalah raster.", + "toolNoOutput": "\"{{tool}}\" tidak menghasilkan keluaran.", + "toolNoUsableOutput": "\"{{tool}}\" tidak menghasilkan keluaran yang dapat digunakan." }, "parameterField": { "selectLayer": "Pilih layer...", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 199e89cd15..ad1d75b2ea 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Modello senza titolo", "untitledModel": "Modello senza titolo", "newModel": "Nuovo", + "arrange": "Disponi", + "arrangeHint": "Dispone i nodi lungo il flusso", "runModel": "Esegui", "cancelRun": "Annulla", "runCancelled": "Esecuzione annullata.", @@ -4072,6 +4074,8 @@ "exportModel": "Esporta", "savedModels": "Modelli salvati", "loadModelPlaceholder": "Carica un modello salvato...", + "deleteModel": "Elimina", + "deletedLog": "Modello eliminato dal progetto.", "searchTools": "Cerca strumenti", "loadingTools": "Caricamento degli strumenti...", "noToolsMatch": "Nessuno strumento corrisponde alla ricerca.", @@ -4085,6 +4089,8 @@ "removeConnection": "Rimuovi collegamento", "removeNode": "Rimuovi nodo", "resizePanel": "Ridimensiona il pannello", + "resizePalette": "Ridimensiona la tavolozza degli strumenti", + "resizeInspector": "Ridimensiona il pannello delle proprietà", "selectNodeHint": "Seleziona un nodo per modificarne le impostazioni.", "sourceLayer": "Livello di origine", "chooseLayer": "Scegli un livello...", @@ -4103,7 +4109,10 @@ "importFailed": "Importazione non riuscita", "importInvalid": "Quel file non contiene un grafo di modello.", "importUnsupported": "Quel file non è un modello GeoLibre.", - "rasterOutputUnsupported": "«{{name}}» è un risultato raster che questa build non può aggiungere alla mappa." + "rasterOutputUnsupported": "«{{name}}» è un risultato raster che questa build non può aggiungere alla mappa.", + "portNeedsVector": "«{{port}}» richiede dati vettoriali, ma è arrivato un raster.", + "toolNoOutput": "«{{tool}}» non ha prodotto alcun risultato.", + "toolNoUsableOutput": "«{{tool}}» non ha prodotto alcun risultato utilizzabile." }, "parameterField": { "selectLayer": "Seleziona un livello...", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 139c5792bc..b64e538b83 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -3984,6 +3984,8 @@ "modelNamePlaceholder": "名称未設定のモデル", "untitledModel": "名称未設定のモデル", "newModel": "新規", + "arrange": "整列", + "arrangeHint": "ノードを処理の流れに沿って並べます", "runModel": "実行", "cancelRun": "キャンセル", "runCancelled": "実行をキャンセルしました。", @@ -4005,6 +4007,8 @@ "exportModel": "エクスポート", "savedModels": "保存済みのモデル", "loadModelPlaceholder": "保存済みモデルを読み込む…", + "deleteModel": "削除", + "deletedLog": "モデルをプロジェクトから削除しました。", "searchTools": "ツールを検索", "loadingTools": "ツールを読み込んでいます…", "noToolsMatch": "検索に一致するツールはありません。", @@ -4018,6 +4022,8 @@ "removeConnection": "接続を削除", "removeNode": "ノードを削除", "resizePanel": "パネルのサイズを変更", + "resizePalette": "ツールパレットのサイズを変更", + "resizeInspector": "プロパティパネルのサイズを変更", "selectNodeHint": "ノードを選択すると設定を編集できます。", "sourceLayer": "ソースレイヤー", "chooseLayer": "レイヤーを選択…", @@ -4036,7 +4042,10 @@ "importFailed": "インポートに失敗しました", "importInvalid": "このファイルにはモデルグラフが含まれていません。", "importUnsupported": "このファイルは GeoLibre のモデルではありません。", - "rasterOutputUnsupported": "「{{name}}」はラスター結果のため、このビルドでは地図に追加できません。" + "rasterOutputUnsupported": "「{{name}}」はラスター結果のため、このビルドでは地図に追加できません。", + "portNeedsVector": "「{{port}}」にはベクターデータが必要ですが、ラスターが渡されました。", + "toolNoOutput": "「{{tool}}」は出力を生成しませんでした。", + "toolNoUsableOutput": "「{{tool}}」は利用できる出力を生成しませんでした。" }, "parameterField": { "selectLayer": "レイヤーを選択...", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index 0dbc949515..bd96005424 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "უსათაურო მოდელი", "untitledModel": "უსათაურო მოდელი", "newModel": "ახალი", + "arrange": "დალაგება", + "arrangeHint": "კვანძების დალაგება ნაკადის მიმართულებით", "runModel": "გაშვება", "cancelRun": "გაუქმება", "runCancelled": "გაშვება გაუქმდა.", @@ -4072,6 +4074,8 @@ "exportModel": "ექსპორტი", "savedModels": "შენახული მოდელები", "loadModelPlaceholder": "შენახული მოდელის ჩატვირთვა...", + "deleteModel": "წაშლა", + "deletedLog": "მოდელი წაშლილია პროექტიდან.", "searchTools": "ხელსაწყოების ძებნა", "loadingTools": "ხელსაწყოები იტვირთება...", "noToolsMatch": "თქვენს ძებნას ხელსაწყო არ ემთხვევა.", @@ -4085,6 +4089,8 @@ "removeConnection": "კავშირის წაშლა", "removeNode": "კვანძის წაშლა", "resizePanel": "პანელის ზომის შეცვლა", + "resizePalette": "ხელსაწყოთა პალიტრის ზომის შეცვლა", + "resizeInspector": "თვისებების პანელის ზომის შეცვლა", "selectNodeHint": "აირჩიეთ კვანძი მისი პარამეტრების შესაცვლელად.", "sourceLayer": "წყაროს ფენა", "chooseLayer": "აირჩიეთ ფენა...", @@ -4103,7 +4109,10 @@ "importFailed": "იმპორტი ვერ მოხერხდა", "importInvalid": "ეს ფაილი მოდელის გრაფს არ შეიცავს.", "importUnsupported": "ეს ფაილი GeoLibre-ის მოდელი არ არის.", - "rasterOutputUnsupported": "„{{name}}“ არის რასტრული შედეგი, რომელსაც ეს ბილდი რუკაზე ვერ დაამატებს." + "rasterOutputUnsupported": "„{{name}}“ არის რასტრული შედეგი, რომელსაც ეს ბილდი რუკაზე ვერ დაამატებს.", + "portNeedsVector": "„{{port}}“ საჭიროებს ვექტორულ მონაცემს, მაგრამ მივიდა რასტრი.", + "toolNoOutput": "„{{tool}}“-მა შედეგი ვერ დააბრუნა.", + "toolNoUsableOutput": "„{{tool}}“-მა გამოსადეგი შედეგი ვერ დააბრუნა." }, "parameterField": { "selectLayer": "აირჩიეთ ფენა...", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index b73644f28f..c58de72dff 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -3984,6 +3984,8 @@ "modelNamePlaceholder": "제목 없는 모델", "untitledModel": "제목 없는 모델", "newModel": "새로 만들기", + "arrange": "정렬", + "arrangeHint": "노드를 처리 흐름에 따라 배치합니다", "runModel": "실행", "cancelRun": "취소", "runCancelled": "실행을 취소했습니다.", @@ -4005,6 +4007,8 @@ "exportModel": "내보내기", "savedModels": "저장된 모델", "loadModelPlaceholder": "저장된 모델 불러오기...", + "deleteModel": "삭제", + "deletedLog": "모델을 프로젝트에서 삭제했습니다.", "searchTools": "도구 검색", "loadingTools": "도구를 불러오는 중...", "noToolsMatch": "검색과 일치하는 도구가 없습니다.", @@ -4018,6 +4022,8 @@ "removeConnection": "연결 제거", "removeNode": "노드 제거", "resizePanel": "패널 크기 조정", + "resizePalette": "도구 팔레트 크기 조정", + "resizeInspector": "속성 패널 크기 조정", "selectNodeHint": "노드를 선택하면 설정을 편집할 수 있습니다.", "sourceLayer": "원본 레이어", "chooseLayer": "레이어 선택...", @@ -4036,7 +4042,10 @@ "importFailed": "가져오기 실패", "importInvalid": "해당 파일에는 모델 그래프가 없습니다.", "importUnsupported": "해당 파일은 GeoLibre 모델이 아닙니다.", - "rasterOutputUnsupported": "\"{{name}}\"은(는) 래스터 결과이며 이 빌드에서는 지도에 추가할 수 없습니다." + "rasterOutputUnsupported": "\"{{name}}\"은(는) 래스터 결과이며 이 빌드에서는 지도에 추가할 수 없습니다.", + "portNeedsVector": "\"{{port}}\"에는 벡터 데이터가 필요하지만 래스터가 전달되었습니다.", + "toolNoOutput": "\"{{tool}}\"이(가) 출력을 생성하지 않았습니다.", + "toolNoUsableOutput": "\"{{tool}}\"이(가) 사용할 수 있는 출력을 생성하지 않았습니다." }, "parameterField": { "selectLayer": "레이어 선택...", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 9ff9020511..98c93a6439 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Naamloos model", "untitledModel": "Naamloos model", "newModel": "Nieuw", + "arrange": "Ordenen", + "arrangeHint": "De knooppunten langs de stroom ordenen", "runModel": "Uitvoeren", "cancelRun": "Annuleren", "runCancelled": "Uitvoeren geannuleerd.", @@ -4072,6 +4074,8 @@ "exportModel": "Exporteren", "savedModels": "Opgeslagen modellen", "loadModelPlaceholder": "Een opgeslagen model laden...", + "deleteModel": "Verwijderen", + "deletedLog": "Model verwijderd uit het project.", "searchTools": "Gereedschappen zoeken", "loadingTools": "Gereedschappen laden...", "noToolsMatch": "Geen gereedschappen komen overeen met uw zoekopdracht.", @@ -4085,6 +4089,8 @@ "removeConnection": "Verbinding verwijderen", "removeNode": "Knooppunt verwijderen", "resizePanel": "Paneelgrootte wijzigen", + "resizePalette": "Grootte van het gereedschapspalet wijzigen", + "resizeInspector": "Grootte van het eigenschappenpaneel wijzigen", "selectNodeHint": "Selecteer een knooppunt om de instellingen te bewerken.", "sourceLayer": "Bronlaag", "chooseLayer": "Kies een laag...", @@ -4103,7 +4109,10 @@ "importFailed": "Importeren mislukt", "importInvalid": "Dat bestand bevat geen modelgraaf.", "importUnsupported": "Dat bestand is geen GeoLibre-model.", - "rasterOutputUnsupported": "“{{name}}” is een rasterresultaat dat deze build niet aan de kaart kan toevoegen." + "rasterOutputUnsupported": "“{{name}}” is een rasterresultaat dat deze build niet aan de kaart kan toevoegen.", + "portNeedsVector": "“{{port}}” heeft vectorgegevens nodig, maar er kwam een raster binnen.", + "toolNoOutput": "“{{tool}}” heeft geen uitvoer opgeleverd.", + "toolNoUsableOutput": "“{{tool}}” heeft geen bruikbare uitvoer opgeleverd." }, "parameterField": { "selectLayer": "Selecteer een laag...", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index f3818f3abf..c5649936e7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Modelo sem título", "untitledModel": "Modelo sem título", "newModel": "Novo", + "arrange": "Organizar", + "arrangeHint": "Dispor os nós ao longo do fluxo", "runModel": "Executar", "cancelRun": "Cancelar", "runCancelled": "Execução cancelada.", @@ -4072,6 +4074,8 @@ "exportModel": "Exportar", "savedModels": "Modelos guardados", "loadModelPlaceholder": "Carregar um modelo guardado...", + "deleteModel": "Excluir", + "deletedLog": "Modelo eliminado do projeto.", "searchTools": "Pesquisar ferramentas", "loadingTools": "A carregar ferramentas...", "noToolsMatch": "Nenhuma ferramenta corresponde à sua pesquisa.", @@ -4085,6 +4089,8 @@ "removeConnection": "Remover ligação", "removeNode": "Remover nó", "resizePanel": "Redimensionar painel", + "resizePalette": "Redimensionar a paleta de ferramentas", + "resizeInspector": "Redimensionar o painel de propriedades", "selectNodeHint": "Selecione um nó para editar as suas definições.", "sourceLayer": "Camada de origem", "chooseLayer": "Escolher uma camada...", @@ -4103,7 +4109,10 @@ "importFailed": "A importação falhou", "importInvalid": "Esse ficheiro não contém um grafo de modelo.", "importUnsupported": "Esse ficheiro não é um modelo do GeoLibre.", - "rasterOutputUnsupported": "«{{name}}» é um resultado raster que esta versão não consegue adicionar ao mapa." + "rasterOutputUnsupported": "«{{name}}» é um resultado raster que esta versão não consegue adicionar ao mapa.", + "portNeedsVector": "«{{port}}» precisa de dados vetoriais, mas chegou um raster.", + "toolNoOutput": "«{{tool}}» não produziu qualquer resultado.", + "toolNoUsableOutput": "«{{tool}}» não produziu qualquer resultado utilizável." }, "parameterField": { "selectLayer": "Selecionar uma camada...", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 5cd6da4936..25527c207e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -4185,6 +4185,8 @@ "modelNamePlaceholder": "Модель без названия", "untitledModel": "Модель без названия", "newModel": "Создать", + "arrange": "Упорядочить", + "arrangeHint": "Расставить узлы вдоль потока обработки", "runModel": "Запустить", "cancelRun": "Отмена", "runCancelled": "Выполнение отменено.", @@ -4206,6 +4208,8 @@ "exportModel": "Экспорт", "savedModels": "Сохранённые модели", "loadModelPlaceholder": "Загрузить сохранённую модель...", + "deleteModel": "Удалить", + "deletedLog": "Модель удалена из проекта.", "searchTools": "Поиск инструментов", "loadingTools": "Загрузка инструментов...", "noToolsMatch": "Нет инструментов, соответствующих запросу.", @@ -4219,6 +4223,8 @@ "removeConnection": "Удалить связь", "removeNode": "Удалить узел", "resizePanel": "Изменить размер панели", + "resizePalette": "Изменить размер палитры инструментов", + "resizeInspector": "Изменить размер панели свойств", "selectNodeHint": "Выберите узел, чтобы изменить его настройки.", "sourceLayer": "Исходный слой", "chooseLayer": "Выберите слой...", @@ -4237,7 +4243,10 @@ "importFailed": "Не удалось импортировать", "importInvalid": "Этот файл не содержит графа модели.", "importUnsupported": "Этот файл не является моделью GeoLibre.", - "rasterOutputUnsupported": "«{{name}}» — растровый результат, который эта сборка не может добавить на карту." + "rasterOutputUnsupported": "«{{name}}» — растровый результат, который эта сборка не может добавить на карту.", + "portNeedsVector": "«{{port}}» требует векторных данных, но поступил растр.", + "toolNoOutput": "«{{tool}}» не выдал результата.", + "toolNoUsableOutput": "«{{tool}}» не выдал пригодного результата." }, "parameterField": { "selectLayer": "Выберите слой...", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 9cdcf28641..99a4f3c873 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -3984,6 +3984,8 @@ "modelNamePlaceholder": "แบบจำลองไม่มีชื่อ", "untitledModel": "แบบจำลองไม่มีชื่อ", "newModel": "ใหม่", + "arrange": "จัดเรียง", + "arrangeHint": "จัดเรียงโหนดตามลำดับการไหลของงาน", "runModel": "เรียกใช้", "cancelRun": "ยกเลิก", "runCancelled": "ยกเลิกการเรียกใช้แล้ว", @@ -4005,6 +4007,8 @@ "exportModel": "ส่งออก", "savedModels": "แบบจำลองที่บันทึกไว้", "loadModelPlaceholder": "โหลดแบบจำลองที่บันทึกไว้...", + "deleteModel": "ลบ", + "deletedLog": "ลบแบบจำลองออกจากโครงการแล้ว", "searchTools": "ค้นหาเครื่องมือ", "loadingTools": "กำลังโหลดเครื่องมือ...", "noToolsMatch": "ไม่มีเครื่องมือที่ตรงกับการค้นหาของคุณ", @@ -4018,6 +4022,8 @@ "removeConnection": "ลบการเชื่อมต่อ", "removeNode": "ลบโหนด", "resizePanel": "ปรับขนาดแผง", + "resizePalette": "ปรับขนาดแผงเครื่องมือ", + "resizeInspector": "ปรับขนาดแผงคุณสมบัติ", "selectNodeHint": "เลือกโหนดเพื่อแก้ไขการตั้งค่า", "sourceLayer": "ชั้นข้อมูลต้นทาง", "chooseLayer": "เลือกชั้นข้อมูล...", @@ -4036,7 +4042,10 @@ "importFailed": "นำเข้าไม่สำเร็จ", "importInvalid": "ไฟล์นั้นไม่มีกราฟของแบบจำลอง", "importUnsupported": "ไฟล์นั้นไม่ใช่แบบจำลองของ GeoLibre", - "rasterOutputUnsupported": "\"{{name}}\" เป็นผลลัพธ์แรสเตอร์ซึ่งรุ่นนี้ไม่สามารถเพิ่มลงในแผนที่ได้" + "rasterOutputUnsupported": "\"{{name}}\" เป็นผลลัพธ์แรสเตอร์ซึ่งรุ่นนี้ไม่สามารถเพิ่มลงในแผนที่ได้", + "portNeedsVector": "\"{{port}}\" ต้องการข้อมูลเวกเตอร์ แต่ได้รับแรสเตอร์", + "toolNoOutput": "\"{{tool}}\" ไม่ได้สร้างผลลัพธ์", + "toolNoUsableOutput": "\"{{tool}}\" ไม่ได้สร้างผลลัพธ์ที่ใช้งานได้" }, "parameterField": { "selectLayer": "เลือกเลเยอร์...", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index ce43499de7..1ce4040cdf 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -4051,6 +4051,8 @@ "modelNamePlaceholder": "Adsız model", "untitledModel": "Adsız model", "newModel": "Yeni", + "arrange": "Düzenle", + "arrangeHint": "Düğümleri akış boyunca dizer", "runModel": "Çalıştır", "cancelRun": "İptal", "runCancelled": "Çalıştırma iptal edildi.", @@ -4072,6 +4074,8 @@ "exportModel": "Dışa aktar", "savedModels": "Kayıtlı modeller", "loadModelPlaceholder": "Kayıtlı bir model yükle...", + "deleteModel": "Sil", + "deletedLog": "Model projeden silindi.", "searchTools": "Araç ara", "loadingTools": "Araçlar yükleniyor...", "noToolsMatch": "Aramanızla eşleşen araç yok.", @@ -4085,6 +4089,8 @@ "removeConnection": "Bağlantıyı kaldır", "removeNode": "Düğümü kaldır", "resizePanel": "Paneli yeniden boyutlandır", + "resizePalette": "Araç paletini yeniden boyutlandır", + "resizeInspector": "Özellikler panelini yeniden boyutlandır", "selectNodeHint": "Ayarlarını düzenlemek için bir düğüm seçin.", "sourceLayer": "Kaynak katman", "chooseLayer": "Bir katman seçin...", @@ -4103,7 +4109,10 @@ "importFailed": "İçe aktarma başarısız", "importInvalid": "Bu dosya bir model grafiği içermiyor.", "importUnsupported": "Bu dosya bir GeoLibre modeli değil.", - "rasterOutputUnsupported": "\"{{name}}\" bir raster sonucudur; bu sürüm bunu haritaya ekleyemez." + "rasterOutputUnsupported": "\"{{name}}\" bir raster sonucudur; bu sürüm bunu haritaya ekleyemez.", + "portNeedsVector": "\"{{port}}\" vektör verisi gerektiriyor ancak bir raster geldi.", + "toolNoOutput": "\"{{tool}}\" hiçbir çıktı üretmedi.", + "toolNoUsableOutput": "\"{{tool}}\" kullanılabilir bir çıktı üretmedi." }, "parameterField": { "selectLayer": "Bir katman seçin...", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index dc72ef6b09..de3ebca4e9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -4070,6 +4070,8 @@ "modelNamePlaceholder": "Mô hình chưa đặt tên", "untitledModel": "Mô hình chưa đặt tên", "newModel": "Mới", + "arrange": "Sắp xếp", + "arrangeHint": "Sắp xếp các nút theo dòng xử lý", "runModel": "Chạy", "cancelRun": "Hủy", "runCancelled": "Đã hủy lần chạy.", @@ -4091,6 +4093,8 @@ "exportModel": "Xuất", "savedModels": "Mô hình đã lưu", "loadModelPlaceholder": "Tải một mô hình đã lưu...", + "deleteModel": "Xóa bỏ", + "deletedLog": "Đã xóa mô hình khỏi dự án.", "searchTools": "Tìm công cụ", "loadingTools": "Đang tải công cụ...", "noToolsMatch": "Không có công cụ nào khớp với tìm kiếm của bạn.", @@ -4104,6 +4108,8 @@ "removeConnection": "Xóa kết nối", "removeNode": "Xóa nút", "resizePanel": "Đổi kích thước bảng", + "resizePalette": "Đổi kích thước bảng công cụ", + "resizeInspector": "Đổi kích thước bảng thuộc tính", "selectNodeHint": "Chọn một nút để chỉnh sửa thiết lập của nó.", "sourceLayer": "Lớp nguồn", "chooseLayer": "Chọn một lớp...", @@ -4122,7 +4128,10 @@ "importFailed": "Nhập thất bại", "importInvalid": "Tệp đó không chứa đồ thị mô hình.", "importUnsupported": "Tệp đó không phải là mô hình GeoLibre.", - "rasterOutputUnsupported": "\"{{name}}\" là kết quả raster mà bản dựng này không thể thêm vào bản đồ." + "rasterOutputUnsupported": "\"{{name}}\" là kết quả raster mà bản dựng này không thể thêm vào bản đồ.", + "portNeedsVector": "\"{{port}}\" cần dữ liệu vector, nhưng lại nhận được raster.", + "toolNoOutput": "\"{{tool}}\" không tạo ra kết quả nào.", + "toolNoUsableOutput": "\"{{tool}}\" không tạo ra kết quả dùng được nào." }, "parameterField": { "selectLayer": "Sao chép liên kết có thể chia sẻ để mở công cụ này với cài đặt hiện tại", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 7a54bc5afe..c1c167a715 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -3984,6 +3984,8 @@ "modelNamePlaceholder": "未命名模型", "untitledModel": "未命名模型", "newModel": "新建", + "arrange": "排列", + "arrangeHint": "沿处理流程排列节点", "runModel": "运行", "cancelRun": "取消", "runCancelled": "已取消运行。", @@ -4005,6 +4007,8 @@ "exportModel": "导出", "savedModels": "已保存的模型", "loadModelPlaceholder": "加载已保存的模型…", + "deleteModel": "删除", + "deletedLog": "模型已从项目中删除。", "searchTools": "搜索工具", "loadingTools": "正在加载工具…", "noToolsMatch": "没有与搜索匹配的工具。", @@ -4018,6 +4022,8 @@ "removeConnection": "删除连接", "removeNode": "删除节点", "resizePanel": "调整面板大小", + "resizePalette": "调整工具面板大小", + "resizeInspector": "调整属性面板大小", "selectNodeHint": "选择一个节点以编辑其设置。", "sourceLayer": "源图层", "chooseLayer": "选择图层…", @@ -4036,7 +4042,10 @@ "importFailed": "导入失败", "importInvalid": "该文件不包含模型图。", "importUnsupported": "该文件不是 GeoLibre 模型。", - "rasterOutputUnsupported": "“{{name}}”是栅格结果,此版本无法将其添加到地图。" + "rasterOutputUnsupported": "“{{name}}”是栅格结果,此版本无法将其添加到地图。", + "portNeedsVector": "“{{port}}”需要矢量数据,但收到的是栅格。", + "toolNoOutput": "“{{tool}}”没有产生输出。", + "toolNoUsableOutput": "“{{tool}}”没有产生可用的输出。" }, "parameterField": { "selectLayer": "选择一个图层...", diff --git a/apps/geolibre-desktop/src/lib/model-graph-edit.ts b/apps/geolibre-desktop/src/lib/model-graph-edit.ts index e6d644106a..e3421eb392 100644 --- a/apps/geolibre-desktop/src/lib/model-graph-edit.ts +++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts @@ -264,12 +264,28 @@ export function createsCycle(graph: ProcessingModelGraph, from: string, to: stri * positions — a hand-written or older pipeline file would otherwise stack every * node at the origin. * + * Only fills positions in when there are none to preserve; use + * {@link layoutGraph} for the user-invoked "arrange" command, which is an + * explicit request to overwrite the hand-placed positions. + * * @param graph The imported graph. * @returns The graph, with positions filled in only if they were all at 0,0. */ export function autoLayout(graph: ProcessingModelGraph): ProcessingModelGraph { const placed = graph.nodes.some((node) => node.x !== 0 || node.y !== 0); - if (placed || graph.nodes.length === 0) return graph; + if (placed) return graph; + return layoutGraph(graph); +} + +/** + * Arrange every node on a left-to-right grid by its depth from the sources, + * discarding the positions it already had. + * + * @param graph The graph to lay out. + * @returns The graph with every node repositioned. + */ +export function layoutGraph(graph: ProcessingModelGraph): ProcessingModelGraph { + if (graph.nodes.length === 0) return graph; const COLUMN = 240; const ROW = 120; // Depth from the sources, so the layout reads left-to-right along the flow. diff --git a/packages/processing/src/model-graph.ts b/packages/processing/src/model-graph.ts index 80357cf26e..501adbf4d8 100644 --- a/packages/processing/src/model-graph.ts +++ b/packages/processing/src/model-graph.ts @@ -457,11 +457,11 @@ export async function runModelGraph( * model authored on the canvas still runs in builds that only understand * `steps`. * - * Only an unambiguous chain projects: one input node, one output node, and - * every tool node with exactly one incoming and one outgoing edge. Anything - * with a branch or a multi-input tool returns `[]`, which is the honest answer - * — such a model has no linear equivalent and older builds must not run a - * silently truncated version of it. + * Only an unambiguous chain projects: one input node feeding exactly one edge, + * one output node fed by exactly one edge, and every tool node with exactly one + * incoming and one outgoing edge. Anything with a branch or a multi-input tool + * returns `[]`, which is the honest answer — such a model has no linear + * equivalent and older builds must not run a silently truncated version of it. * * @param graph The authored graph. * @returns The equivalent step chain, or `[]` when there is not one. @@ -486,6 +486,14 @@ export function graphToLinearSteps( incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); outgoing.set(edge.from, (outgoing.get(edge.from) ?? 0) + 1); } + // A branch through the shared input or output node keeps every tool node at + // in-degree 1 / out-degree 1, so the per-tool counts above would wave it + // through and `runModel` would then run the branches as a strict chain — the + // silent truncation this function exists to refuse. + if ((outgoing.get(inputs[0].id) ?? 0) !== 1) return []; + if ((incoming.get(outs[0].id) ?? 0) !== 1) return []; + + const byId = new Map(graph.nodes.map((node) => [node.id, node])); const steps: { id: string; toolId: string; @@ -499,10 +507,19 @@ export function graphToLinearSteps( // Only client vector tools have a `steps` runner to fall back to. if (node.provider !== "vector" || !node.toolId) return []; const inputEdge = graph.edges.find((edge) => edge.to === node.id && nodeIds.has(edge.from)); + const parameters = { ...(node.parameters ?? {}) }; + // The source layer lives on the `input` node, not in any tool's parameters, + // but `runModel` overrides the input parameter only from step 1 onwards — + // step 0 reads its layer straight out of `parameters`. Without this the + // fallback chain fails at its very first step. + const source = inputEdge ? byId.get(inputEdge.from) : undefined; + if (source?.kind === "input" && source.layerId) { + parameters[inputEdge?.toPort ?? "layer"] = source.layerId; + } steps.push({ id: node.id, toolId: node.toolId, - parameters: { ...(node.parameters ?? {}) }, + parameters, ...(inputEdge && inputEdge.toPort !== "layer" ? { inputParam: inputEdge.toPort } : {}), }); } diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts index 7f97eb1534..ed1401935b 100644 --- a/tests/model-graph-edit.test.ts +++ b/tests/model-graph-edit.test.ts @@ -9,6 +9,7 @@ import { connectNodes, createsCycle, emptyModelGraph, + layoutGraph, moveNode, removeEdge, removeNode, @@ -333,4 +334,29 @@ describe("auto layout", () => { assert.equal(laid.nodes[0].x, laid.nodes[1].x); assert.notEqual(laid.nodes[0].y, laid.nodes[1].y); }); + + it("re-lays hand-placed nodes when the user asks for it", () => { + // autoLayout deliberately leaves a positioned graph alone; the Arrange + // button is the explicit request to overwrite those positions, so it goes + // through layoutGraph instead. + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "input", x: 900, y: 400, layerId: "roads" }, + { id: "b", kind: "tool", x: 30, y: 40, provider: "vector", toolId: "buffer" }, + { id: "c", kind: "output", x: 120, y: 500, name: "Out" }, + ], + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "c", toPort: "in" }, + ], + }; + const laid = layoutGraph(graph); + const x = Object.fromEntries(laid.nodes.map((node) => [node.id, node.x])); + assert.ok(x.a < x.b && x.b < x.c); + }); + + it("leaves an empty graph untouched when arranging", () => { + const graph: ProcessingModelGraph = { nodes: [], edges: [] }; + assert.deepEqual(layoutGraph(graph), graph); + }); }); diff --git a/tests/model-graph.test.ts b/tests/model-graph.test.ts index e8f76315e9..37403785fa 100644 --- a/tests/model-graph.test.ts +++ b/tests/model-graph.test.ts @@ -475,7 +475,40 @@ describe("legacy linear projection", () => { steps.map((step) => step.toolId), ["buffer"], ); - assert.deepEqual(steps[0].parameters, { distance: 50 }); + // The source layer lives on the input node, and runModel only overrides a + // step's input parameter from step 1 onwards — so step 0 has to carry it or + // the fallback chain fails on its very first tool. + assert.deepEqual(steps[0].parameters, { distance: 50, layer: "roads" }); + }); + + it("carries the source layer into a non-default input parameter too", () => { + const graph = chainGraph(); + graph.edges[0].toPort = "input"; + const steps = graphToLinearSteps(graph); + assert.equal(steps[0].inputParam, "input"); + assert.deepEqual(steps[0].parameters, { distance: 50, input: "roads" }); + }); + + it("refuses to project a branch that shares one input node", () => { + // Every tool node still has in-degree 1 and out-degree 1 here, so only the + // input node's own fan-out reveals that this is not a linear chain. Left + // unchecked it would project as [a, b] and runModel would feed b from a's + // output instead of from the shared input. + const graph: ProcessingModelGraph = { + nodes: [ + { id: "in1", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "a", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }, + { id: "b", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "centroids" }, + { id: "o", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "in1", fromPort: "out", to: "a", toPort: "layer" }, + { id: "e2", from: "in1", fromPort: "out", to: "b", toPort: "layer" }, + { id: "e3", from: "a", fromPort: "out", to: "o", toPort: "in" }, + { id: "e4", from: "b", fromPort: "out", to: "o", toPort: "in" }, + ], + }; + assert.deepEqual(graphToLinearSteps(graph), []); }); it("refuses to project a multi-input tool rather than truncating it", () => { From e63744b308deeab17dcd2af24de07658d6a92b55 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 21:56:06 -0400 Subject: [PATCH 17/22] Address Claude review feedback - connectPorts now refuses an edge whose source or target node no longer exists. Arming an output port, deleting that node, then activating an input port built an edge onto a ghost: GraphEdges resolves no anchor for it, so neither the curve nor its click-to-remove hit area rendered, leaving a dangling-edge issue with no way to clear it. Removing a node also clears an armed port that pointed at it, so the UI state stops lying. - Activating an already-wired input port with nothing armed disconnects it. Edge removal was click-the-curve only, so a keyboard user who mis-wired two ports had no way to undo it. - The panel's own resize grip is now focusable and arrow-key resizable, like the column splitters. Grow/shrink follows the computed writing direction and clamps to the container the same way the pointer drag does. - The in-progress link line coalesces its pointermove updates into one commit per animation frame, matching what the node drag already does; each update repaints every edge in GraphEdges. - Run stays disabled while the tool catalog is empty. `issues` short-circuits to [] in that window, so a just-loaded model looked runnable before any tool could resolve. Verified in a browser: keyboard wiring creates an edge and re-activating the same input port removes it; arming a port, deleting its node, then activating another input port produces no dangling-edge issue; the panel resizes 16px per arrow press in all four directions. Left open: the report that portLabel's two branches are swapped. INPUT_NODE_PORT is the string "out" (it names the port that input nodes expose, which is an output port), so mapping it to "Output" is the faithful reading of the label; the suggested swap would render the literal "out" as "Input". --- .../model-builder/ModelBuilderPanel.tsx | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index ecf951f036..b65400999c 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -551,6 +551,12 @@ export function ModelBuilderPanel({ const connectPorts = useCallback( (from: { nodeId: string; portId: string }, to: { nodeId: string; portId: string }) => { setGraph((current) => { + // connectNodes does not check that both endpoints still exist, and an + // edge onto a deleted node renders nowhere — GraphEdges resolves no + // anchor for it, so neither the curve nor its click-to-remove hit area + // is drawn, leaving a dangling-edge issue with no way to clear it. + if (!current.nodes.some((node) => node.id === from.nodeId)) return current; + if (!current.nodes.some((node) => node.id === to.nodeId)) return current; const result = connectNodes(current, from, to, createId); if ("rejected" in result) { appendLog( @@ -572,6 +578,10 @@ export function ModelBuilderPanel({ * Space) fires `click`, never `pointerdown`, so without this the whole * canvas is unusable without a pointing device. Activating the armed port * again disarms it, so there is a way out that does not need the mouse. + * + * Activating an already-wired input port with nothing armed disconnects it. + * Removing an edge is otherwise only possible by clicking its curve, which + * leaves a keyboard user who mis-wires two ports with no way to undo it. */ const handlePortActivate = useCallback( (side: "in" | "out", nodeId: string, portId: string) => { @@ -584,7 +594,16 @@ export function ModelBuilderPanel({ return; } setArmedPort((current) => { - if (current) connectPorts(current, { nodeId, portId }); + if (current) { + connectPorts(current, { nodeId, portId }); + } else { + setGraph((graphNow) => { + const wired = graphNow.edges.find( + (edge) => edge.to === nodeId && edge.toPort === portId, + ); + return wired ? removeEdge(graphNow, wired.id) : graphNow; + }); + } return null; }); }, @@ -599,11 +618,23 @@ export function ModelBuilderPanel({ setLinking({ nodeId, portId, x: start.x, y: start.y }); const handle = event.currentTarget; handle.setPointerCapture(event.pointerId); + // Coalesced to one commit per frame for the same reason the node drag is: + // each update repaints every edge in GraphEdges, and a high-poll-rate + // pointer reports several moves per frame. + let frame = 0; + let pending: { x: number; y: number } | null = null; + const commit = () => { + frame = 0; + const next = pending; + pending = null; + if (next) setLinking((current) => (current ? { ...current, ...next } : current)); + }; const handleMove = (move: PointerEvent) => { - const point = canvasPoint(move.clientX, move.clientY); - setLinking((current) => (current ? { ...current, x: point.x, y: point.y } : current)); + pending = canvasPoint(move.clientX, move.clientY); + if (!frame) frame = requestAnimationFrame(commit); }; const handleEnd = (end: PointerEvent) => { + if (frame) cancelAnimationFrame(frame); if (handle.hasPointerCapture(end.pointerId)) handle.releasePointerCapture(end.pointerId); handle.removeEventListener("pointermove", handleMove); handle.removeEventListener("pointerup", handleEnd); @@ -804,6 +835,31 @@ export function ModelBuilderPanel({ [maxSideWidth], ); + /** + * Keyboard path for the panel's own resize grip, matching the column + * splitters: arrows grow or shrink the panel a step at a time. The panel is + * clamped to its container the same way the pointer drag is. + */ + const handleResizeKey = useCallback( + (event: ReactKeyboardEvent) => { + const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0; + const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0; + if (!dx && !dy) return; + event.preventDefault(); + const dirSign = getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1; + const bounds = ( + event.currentTarget.closest("section") as HTMLElement | null + )?.parentElement?.getBoundingClientRect(); + const maxWidth = bounds ? bounds.width - position.x - EDGE_MARGIN : Infinity; + const maxHeight = bounds ? bounds.height - position.y - EDGE_MARGIN : Infinity; + setSize((current) => ({ + width: clamp(current.width + dx * dirSign, MIN_WIDTH, Math.max(MIN_WIDTH, maxWidth)), + height: clamp(current.height + dy, MIN_HEIGHT, Math.max(MIN_HEIGHT, maxHeight)), + })); + }, + [position.x, position.y], + ); + const handleResizeStart = (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -927,7 +983,15 @@ export function ModelBuilderPanel({ size="sm" className="h-7 gap-1 px-2" onClick={() => void handleRun()} - disabled={issues.length > 0 || catalogFailed || graph.nodes.length === 0} + // catalog.length === 0 is also the window where `issues` is + // short-circuited to [], so without it Run looks enabled on a + // just-loaded model that no tool can resolve yet. + disabled={ + issues.length > 0 || + catalogFailed || + catalog.length === 0 || + graph.nodes.length === 0 + } > {t("processing.modelBuilder.runModel")} @@ -1133,6 +1197,9 @@ export function ModelBuilderPanel({ onRemove={() => { if (!selectedNode) return; setGraph((current) => removeNode(current, selectedNode.id)); + // An armed port on the node being deleted would otherwise stay + // armed and wire the next activation to a node that is gone. + setArmedPort((current) => (current?.nodeId === selectedNode.id ? null : current)); setSelectedNodeId(null); }} /> @@ -1216,9 +1283,11 @@ export function ModelBuilderPanel({ {/* Resize grip */}
); From 70b7ac8db08bac385978b6ab6f4388550c256e3f Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:12:44 -0400 Subject: [PATCH 18/22] Confirm before discarding an unsaved model Clicking New threw the canvas away without asking. Load (the saved-models picker) and Import replace it just as destructively, so all three now go through one `confirmDiscard` gate, using the blocking `window.confirm` the rest of the app uses for a discard (PythonEditorPane, StoryMapPanel). The gate only fires on real unsaved work. `dirty` compares the canvas against the copy the project holds for this model id: an untouched empty canvas never prompts, a model that has never been saved is dirty as soon as it has a node or a name, and a saved one is dirty when its name or graph has moved on. That comparison is `graphsEqual` in model-graph-edit.ts, which ignores object key order and array order but not content. Both matter: a node's `parameters` are built by several code paths so the same model can stringify two ways, and settleNode re-appends a dragged node so it paints last, reordering `nodes` without changing the model. Either would otherwise report an edit that is not there. Positions *are* compared, since moving a card is an edit the user would not expect New to discard silently. Verified in a browser: New on an empty canvas does not prompt; after adding a node it prompts and dismissing keeps the work while accepting clears it; Save-then-New and Load-then-New both stay silent (autoLayout on load does not read as an edit); loading over an edited canvas prompts and dismissing keeps the work. --- .../model-builder/ModelBuilderPanel.tsx | 39 +++++++++- .../geolibre-desktop/src/i18n/locales/ar.json | 1 + .../geolibre-desktop/src/i18n/locales/de.json | 1 + .../geolibre-desktop/src/i18n/locales/en.json | 1 + .../geolibre-desktop/src/i18n/locales/es.json | 1 + .../geolibre-desktop/src/i18n/locales/fa.json | 1 + .../geolibre-desktop/src/i18n/locales/fr.json | 1 + .../geolibre-desktop/src/i18n/locales/hi.json | 1 + .../geolibre-desktop/src/i18n/locales/id.json | 1 + .../geolibre-desktop/src/i18n/locales/it.json | 1 + .../geolibre-desktop/src/i18n/locales/ja.json | 1 + .../geolibre-desktop/src/i18n/locales/ka.json | 1 + .../geolibre-desktop/src/i18n/locales/ko.json | 1 + .../geolibre-desktop/src/i18n/locales/nl.json | 1 + .../geolibre-desktop/src/i18n/locales/pt.json | 1 + .../geolibre-desktop/src/i18n/locales/ru.json | 1 + .../geolibre-desktop/src/i18n/locales/th.json | 1 + .../geolibre-desktop/src/i18n/locales/tr.json | 1 + .../geolibre-desktop/src/i18n/locales/vi.json | 1 + .../geolibre-desktop/src/i18n/locales/zh.json | 1 + .../src/lib/model-graph-edit.ts | 43 +++++++++++ tests/model-graph-edit.test.ts | 75 +++++++++++++++++++ 22 files changed, 173 insertions(+), 3 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index b65400999c..6b7d4d2319 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -71,6 +71,7 @@ import { autoLayout, connectNodes, emptyModelGraph, + graphsEqual, layoutGraph, moveNode, removeEdge, @@ -310,23 +311,54 @@ export function ModelBuilderPanel({ setLog([]); }, [abortRun]); + /** The name Save would write, so the dirty check compares like with like. */ + const savedName = modelName.trim() || t("processing.modelBuilder.untitledModel"); + + /** + * True when the canvas holds work Save has not written to the project. + * + * An untouched empty canvas is not unsaved work, so a freshly opened panel + * never prompts. A model that has never been saved counts as dirty as soon + * as it has a node or a name. + */ + const dirty = useMemo(() => { + if (graph.nodes.length === 0 && graph.edges.length === 0 && !modelName.trim()) return false; + const saved = savedModels.find((model) => model.id === modelId); + if (!saved) return true; + if (saved.name !== savedName) return true; + return !graphsEqual(saved.graph ?? emptyModelGraph(), graph); + }, [graph, modelName, savedName, modelId, savedModels]); + + /** + * Gate an action that replaces the canvas. New, Load and Import all throw + * the current model away, so each asks first rather than discarding unsaved + * work silently. `window.confirm` is blocking and matches how the rest of + * the app confirms a discard (see PythonEditorPane). + */ + const confirmDiscard = useCallback( + () => !dirty || window.confirm(t("processing.modelBuilder.discardChanges")), + [dirty, t], + ); + const handleNewModel = useCallback(() => { + if (!confirmDiscard()) return; setModelId(createId()); setModelName(""); setGraph(emptyModelGraph()); setSelectedNodeId(null); resetRunState(); - }, [resetRunState]); + }, [confirmDiscard, resetRunState]); const handleLoadModel = useCallback( (model: ProcessingModel) => { + if (!confirmDiscard()) return; setModelId(model.id); setModelName(model.name); setGraph(autoLayout(model.graph ?? stepsToGraph(model))); setSelectedNodeId(null); resetRunState(); }, - [resetRunState], + [confirmDiscard, resetRunState], ); /** @@ -384,6 +416,7 @@ export function ModelBuilderPanel({ const handleImport = useCallback( async (file: File) => { + if (!confirmDiscard()) return; try { // Bounded before the read, so an obviously-too-large file never gets // decoded and parsed in full just to be rejected afterwards. @@ -421,7 +454,7 @@ export function ModelBuilderPanel({ appendLog(`${t("processing.modelBuilder.importFailed")}: ${(err as Error).message}`); } }, - [appendLog, resetRunState, t], + [appendLog, confirmDiscard, resetRunState, t], ); // --- Canvas interaction ------------------------------------------------- diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index d9262eb130..00121afb08 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -4318,6 +4318,7 @@ "modelNamePlaceholder": "نموذج بلا عنوان", "untitledModel": "نموذج بلا عنوان", "newModel": "جديد", + "discardChanges": "هل تريد تجاهل التغييرات غير المحفوظة في النموذج الحالي؟", "arrange": "ترتيب", "arrangeHint": "ترتيب العقد على امتداد مسار التدفق", "runModel": "تشغيل", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 54d4d08647..e64ff65fb5 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Unbenanntes Modell", "untitledModel": "Unbenanntes Modell", "newModel": "Neu", + "discardChanges": "Nicht gespeicherte Änderungen am aktuellen Modell verwerfen?", "arrange": "Anordnen", "arrangeHint": "Die Knoten entlang des Ablaufs anordnen", "runModel": "Ausführen", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index dd4381ed3c..38ca66b2f4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4061,6 +4061,7 @@ "modelNamePlaceholder": "Untitled model", "untitledModel": "Untitled model", "newModel": "New", + "discardChanges": "Discard unsaved changes to the current model?", "arrange": "Arrange", "arrangeHint": "Lay the nodes out along the flow", "runModel": "Run", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index e3cbb671b8..99077d89e2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Modelo sin título", "untitledModel": "Modelo sin título", "newModel": "Nuevo", + "discardChanges": "¿Descartar los cambios sin guardar del modelo actual?", "arrange": "Organizar", "arrangeHint": "Distribuir los nodos siguiendo el flujo", "runModel": "Ejecutar", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 75a794ab1a..432ba4415e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "مدل بدون عنوان", "untitledModel": "مدل بدون عنوان", "newModel": "جدید", + "discardChanges": "تغییرات ذخیره‌نشدهٔ مدل کنونی دور انداخته شوند؟", "arrange": "چیدمان", "arrangeHint": "چیدن گره‌ها در امتداد جریان", "runModel": "اجرا", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 876d19d303..77595ac200 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Modèle sans titre", "untitledModel": "Modèle sans titre", "newModel": "Nouveau", + "discardChanges": "Annuler les modifications non enregistrées du modèle actuel ?", "arrange": "Organiser", "arrangeHint": "Disposer les nœuds le long du flux", "runModel": "Exécuter", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 1c60e3fbb1..28bbb01f78 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "बिना शीर्षक मॉडल", "untitledModel": "बिना शीर्षक मॉडल", "newModel": "नया", + "discardChanges": "वर्तमान मॉडल में असेव्ड बदलाव त्यागें?", "arrange": "व्यवस्थित करें", "arrangeHint": "नोड्स को प्रवाह के अनुसार व्यवस्थित करें", "runModel": "चलाएँ", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 4c887d1c58..c3caa065b3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -3984,6 +3984,7 @@ "modelNamePlaceholder": "Model tanpa judul", "untitledModel": "Model tanpa judul", "newModel": "Baru", + "discardChanges": "Buang perubahan yang belum disimpan pada model saat ini?", "arrange": "Tata", "arrangeHint": "Menata simpul mengikuti alur", "runModel": "Jalankan", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index ad1d75b2ea..1a56fe9e77 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Modello senza titolo", "untitledModel": "Modello senza titolo", "newModel": "Nuovo", + "discardChanges": "Scartare le modifiche non salvate del modello corrente?", "arrange": "Disponi", "arrangeHint": "Dispone i nodi lungo il flusso", "runModel": "Esegui", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index b64e538b83..394a72137c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -3984,6 +3984,7 @@ "modelNamePlaceholder": "名称未設定のモデル", "untitledModel": "名称未設定のモデル", "newModel": "新規", + "discardChanges": "現在のモデルの未保存の変更を破棄しますか?", "arrange": "整列", "arrangeHint": "ノードを処理の流れに沿って並べます", "runModel": "実行", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index bd96005424..e55ae0fb36 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "უსათაურო მოდელი", "untitledModel": "უსათაურო მოდელი", "newModel": "ახალი", + "discardChanges": "უარვყოთ მიმდინარე მოდელის შეუნახავი ცვლილებები?", "arrange": "დალაგება", "arrangeHint": "კვანძების დალაგება ნაკადის მიმართულებით", "runModel": "გაშვება", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index c58de72dff..4a1a495fa2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -3984,6 +3984,7 @@ "modelNamePlaceholder": "제목 없는 모델", "untitledModel": "제목 없는 모델", "newModel": "새로 만들기", + "discardChanges": "현재 모델의 저장되지 않은 변경 사항을 취소하시겠습니까?", "arrange": "정렬", "arrangeHint": "노드를 처리 흐름에 따라 배치합니다", "runModel": "실행", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 98c93a6439..f025751546 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Naamloos model", "untitledModel": "Naamloos model", "newModel": "Nieuw", + "discardChanges": "Niet-opgeslagen wijzigingen aan het huidige model verwerpen?", "arrange": "Ordenen", "arrangeHint": "De knooppunten langs de stroom ordenen", "runModel": "Uitvoeren", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index c5649936e7..3b15c1df3b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Modelo sem título", "untitledModel": "Modelo sem título", "newModel": "Novo", + "discardChanges": "Descartar alterações não guardadas no modelo atual?", "arrange": "Organizar", "arrangeHint": "Dispor os nós ao longo do fluxo", "runModel": "Executar", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 25527c207e..099c1dca49 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -4185,6 +4185,7 @@ "modelNamePlaceholder": "Модель без названия", "untitledModel": "Модель без названия", "newModel": "Создать", + "discardChanges": "Отменить несохранённые изменения текущей модели?", "arrange": "Упорядочить", "arrangeHint": "Расставить узлы вдоль потока обработки", "runModel": "Запустить", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 99a4f3c873..ba66b73895 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -3984,6 +3984,7 @@ "modelNamePlaceholder": "แบบจำลองไม่มีชื่อ", "untitledModel": "แบบจำลองไม่มีชื่อ", "newModel": "ใหม่", + "discardChanges": "ทิ้งการเปลี่ยนแปลงที่ยังไม่ได้บันทึกของแบบจำลองปัจจุบันหรือไม่?", "arrange": "จัดเรียง", "arrangeHint": "จัดเรียงโหนดตามลำดับการไหลของงาน", "runModel": "เรียกใช้", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 1ce4040cdf..3294b8e867 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -4051,6 +4051,7 @@ "modelNamePlaceholder": "Adsız model", "untitledModel": "Adsız model", "newModel": "Yeni", + "discardChanges": "Mevcut modelin kaydedilmemiş değişiklikleri atılsın mı?", "arrange": "Düzenle", "arrangeHint": "Düğümleri akış boyunca dizer", "runModel": "Çalıştır", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index de3ebca4e9..bb07f7c304 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -4070,6 +4070,7 @@ "modelNamePlaceholder": "Mô hình chưa đặt tên", "untitledModel": "Mô hình chưa đặt tên", "newModel": "Mới", + "discardChanges": "Hủy các thay đổi chưa được lưu đối với mô hình hiện tại?", "arrange": "Sắp xếp", "arrangeHint": "Sắp xếp các nút theo dòng xử lý", "runModel": "Chạy", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index c1c167a715..1647ae619d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -3984,6 +3984,7 @@ "modelNamePlaceholder": "未命名模型", "untitledModel": "未命名模型", "newModel": "新建", + "discardChanges": "是否放弃当前模型的未保存更改?", "arrange": "排列", "arrangeHint": "沿处理流程排列节点", "runModel": "运行", diff --git a/apps/geolibre-desktop/src/lib/model-graph-edit.ts b/apps/geolibre-desktop/src/lib/model-graph-edit.ts index e3421eb392..94a7d14597 100644 --- a/apps/geolibre-desktop/src/lib/model-graph-edit.ts +++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts @@ -341,3 +341,46 @@ export function layoutGraph(graph: ProcessingModelGraph): ProcessingModelGraph { }), }; } + +/** + * Serialize a value with object keys in a stable order, so two structurally + * equal values always produce the same string. + * + * `JSON.stringify` preserves insertion order, and a node's `parameters` are + * built up by different code paths (typed in the inspector, restored from a + * project file, copied from a descriptor default), so the same model can + * stringify two ways. Comparing those raw would report an edit that is not + * there. + */ +function stableKey(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(stableKey).join(",")}]`; + const entries = Object.entries(value as Record) + // An absent key and a key set to undefined mean the same thing here, and + // JSON.stringify drops the latter — so drop it on both sides. + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableKey(entry)}`).join(",")}}`; +} + +/** + * Compare two graphs by content, ignoring key order and the order nodes and + * edges happen to sit in their arrays. + * + * Backs the Model Builder's unsaved-work check. Array order is deliberately + * ignored: {@link settleNode} re-appends a dragged node so it paints last, which + * reorders `nodes` without changing the model. Positions, on the other hand, + * *are* compared — moving a card is an edit the user would not expect a New or + * Load to throw away without asking. + * + * @param a One graph. + * @param b The other. + * @returns True when the two describe the same model. + */ +export function graphsEqual(a: ProcessingModelGraph, b: ProcessingModelGraph): boolean { + if (a === b) return true; + if (a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) return false; + const canonical = (graph: ProcessingModelGraph): string => + `${graph.nodes.map(stableKey).sort().join("|")}#${graph.edges.map(stableKey).sort().join("|")}`; + return canonical(a) === canonical(b); +} diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts index ed1401935b..9895542619 100644 --- a/tests/model-graph-edit.test.ts +++ b/tests/model-graph-edit.test.ts @@ -9,6 +9,7 @@ import { connectNodes, createsCycle, emptyModelGraph, + graphsEqual, layoutGraph, moveNode, removeEdge, @@ -360,3 +361,77 @@ describe("auto layout", () => { assert.deepEqual(layoutGraph(graph), graph); }); }); + +describe("graphsEqual", () => { + const base = (): ProcessingModelGraph => ({ + nodes: [ + { id: "a", kind: "input", x: 10, y: 20, layerId: "roads" }, + { + id: "b", + kind: "tool", + x: 30, + y: 40, + provider: "vector", + toolId: "buffer", + parameters: { distance: 5, units: "km" }, + }, + ], + edges: [{ id: "e1", from: "a", fromPort: "out", to: "b", toPort: "layer" }], + }); + + it("treats a graph as equal to itself", () => { + const graph = base(); + assert.equal(graphsEqual(graph, graph), true); + assert.equal(graphsEqual(graph, base()), true); + }); + + it("ignores the order keys were written in", () => { + // Parameters are built up by several code paths, so the same model can + // stringify two ways; that must not read as an unsaved edit. + const other = base(); + other.nodes[1].parameters = { units: "km", distance: 5 }; + assert.equal(graphsEqual(base(), other), true); + }); + + it("ignores the order nodes sit in the array", () => { + // settleNode re-appends a dragged node so it paints last, which reorders + // `nodes` without changing the model. + const other = base(); + other.nodes.reverse(); + assert.equal(graphsEqual(base(), other), true); + }); + + it("treats an absent key and an undefined one as the same", () => { + const other = base(); + other.nodes[0].name = undefined; + assert.equal(graphsEqual(base(), other), true); + }); + + it("sees a moved node as a change", () => { + const other = base(); + other.nodes[0].x = 999; + assert.equal(graphsEqual(base(), other), false); + }); + + it("sees an edited parameter as a change", () => { + const other = base(); + other.nodes[1].parameters = { distance: 6, units: "km" }; + assert.equal(graphsEqual(base(), other), false); + }); + + it("sees an added or removed edge as a change", () => { + const other = base(); + other.edges = []; + assert.equal(graphsEqual(base(), other), false); + }); + + it("sees a rewired edge as a change", () => { + const other = base(); + other.edges[0].toPort = "overlay"; + assert.equal(graphsEqual(base(), other), false); + }); + + it("treats two empty graphs as equal", () => { + assert.equal(graphsEqual(emptyModelGraph(), emptyModelGraph()), true); + }); +}); From d20c85b0f295fad8d22cb00b0938a2e3c641e2be Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:28:15 -0400 Subject: [PATCH 19/22] Wrap the Arrange layout, resize the log pane, keep intermediate results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrange only ever grew rightwards: every depth got its own column, so a chain of more than two or three tools ran off the edge of the canvas and Arrange pushed work out of view instead of tidying it into view. layoutGraph now takes the visible canvas width and wraps: when the next depth will not fit, it starts a fresh band below the deepest node of the current one, and the canvas scrolls back to the origin afterwards. Passing no width keeps the old single-band behaviour, which is what the unit tests without a viewport rely on. autoLayout forwards the same width, so an imported model lands in view too. The bottom message log is now draggable, with a keyboard path on its splitter and a bound tying it to the panel height so the canvas cannot be squeezed away. Intermediate results: a model keeps only what an `output` node is wired to, so a mid-chain tool's result was computed and discarded. The engine already supported this — an output port can feed the next tool *and* an output node, and runModelGraph keys its outputs per node — but nothing said so, and it took adding a second output node and hand-wiring a fan-out to discover. A selected tool node now offers "Keep this result" per output port, which drops a wired output node beside it in one click and reads back as "This result is kept" once there is one. Named per port only when a tool has more than one output, since otherwise the port name is noise. Verified in a browser against a dropped GeoJSON: a six-node chain arranges into three visible bands (rightmost edge 448px inside a 526px canvas) instead of running to 1240px; the log pane drags 96 -> 188 and steps back to 140 by arrow key; and input -> Buffer -> Centroids with "Keep this result" on Buffer runs to "2 output(s) added", putting both the intermediate buffer and the final centroids on the map. --- .../model-builder/ModelBuilderPanel.tsx | 153 +++++++++++++++++- .../geolibre-desktop/src/i18n/locales/ar.json | 6 + .../geolibre-desktop/src/i18n/locales/de.json | 6 + .../geolibre-desktop/src/i18n/locales/en.json | 6 + .../geolibre-desktop/src/i18n/locales/es.json | 6 + .../geolibre-desktop/src/i18n/locales/fa.json | 6 + .../geolibre-desktop/src/i18n/locales/fr.json | 6 + .../geolibre-desktop/src/i18n/locales/hi.json | 6 + .../geolibre-desktop/src/i18n/locales/id.json | 6 + .../geolibre-desktop/src/i18n/locales/it.json | 6 + .../geolibre-desktop/src/i18n/locales/ja.json | 6 + .../geolibre-desktop/src/i18n/locales/ka.json | 6 + .../geolibre-desktop/src/i18n/locales/ko.json | 6 + .../geolibre-desktop/src/i18n/locales/nl.json | 6 + .../geolibre-desktop/src/i18n/locales/pt.json | 6 + .../geolibre-desktop/src/i18n/locales/ru.json | 6 + .../geolibre-desktop/src/i18n/locales/th.json | 6 + .../geolibre-desktop/src/i18n/locales/tr.json | 6 + .../geolibre-desktop/src/i18n/locales/vi.json | 6 + .../geolibre-desktop/src/i18n/locales/zh.json | 6 + .../src/lib/model-graph-edit.ts | 146 +++++++++++++++-- tests/model-graph-edit.test.ts | 128 +++++++++++++++ 22 files changed, 522 insertions(+), 19 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 6b7d4d2319..9920fbd9bf 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -67,6 +67,7 @@ import { NODE_HEIGHT, NODE_WIDTH, addDataNode, + addOutputForPort, addToolNode, autoLayout, connectNodes, @@ -74,6 +75,7 @@ import { graphsEqual, layoutGraph, moveNode, + portFeedsOutput, removeEdge, removeNode, setNodeField, @@ -94,6 +96,13 @@ const MIN_SIDE_WIDTH = 140; const MAX_SIDE_WIDTH = 480; const DEFAULT_PALETTE_WIDTH = 208; const DEFAULT_INSPECTOR_WIDTH = 224; +/** Bounds on the draggable log pane, in pixels. */ +const MIN_LOG_HEIGHT = 56; +const MAX_LOG_HEIGHT = 420; +const DEFAULT_LOG_HEIGHT = 96; +/** Share of the panel height the log pane may take, so the canvas survives. */ +const MAX_LOG_FRACTION = 0.6; + /** * Share of the panel a single side column may take. Both columns are * `shrink-0`, so without this two columns dragged wide (or a panel later @@ -165,6 +174,7 @@ export function ModelBuilderPanel({ const [size, setSize] = useState({ width: 980, height: 560 }); const [paletteWidth, setPaletteWidth] = useState(DEFAULT_PALETTE_WIDTH); const [inspectorWidth, setInspectorWidth] = useState(DEFAULT_INSPECTOR_WIDTH); + const [logHeight, setLogHeight] = useState(DEFAULT_LOG_HEIGHT); const [modelId, setModelId] = useState(() => createId()); const [modelName, setModelName] = useState(""); const [graph, setGraph] = useState(emptyModelGraph); @@ -340,6 +350,17 @@ export function ModelBuilderPanel({ [dirty, t], ); + /** + * Room the layout gets to work with: the canvas's own visible width, so a + * long chain wraps into bands that stay on screen rather than running off + * the right edge. `clientWidth` excludes the scrollbar, which is what the + * nodes actually have to fit inside. + */ + const layoutOptions = useCallback( + () => ({ width: canvasRef.current?.clientWidth || undefined }), + [], + ); + const handleNewModel = useCallback(() => { if (!confirmDiscard()) return; setModelId(createId()); @@ -354,11 +375,11 @@ export function ModelBuilderPanel({ if (!confirmDiscard()) return; setModelId(model.id); setModelName(model.name); - setGraph(autoLayout(model.graph ?? stepsToGraph(model))); + setGraph(autoLayout(model.graph ?? stepsToGraph(model), layoutOptions())); setSelectedNodeId(null); resetRunState(); }, - [confirmDiscard, resetRunState], + [confirmDiscard, layoutOptions, resetRunState], ); /** @@ -373,10 +394,25 @@ export function ModelBuilderPanel({ appendLog(t("processing.modelBuilder.deletedLog")); }, [savedModels, deleteModel, modelId, appendLog, t]); + /** + * Keep a tool's result: drop an `output` node next to it and wire the two. + * The port can still feed the next tool as well, so keeping an intermediate + * step costs nothing downstream. + */ + const handleKeepResult = useCallback((nodeId: string, portId: string) => { + setGraph((current) => { + const next = addOutputForPort(current, nodeId, portId, createId); + return next ? next.graph : current; + }); + }, []); + /** Re-run the depth-based layout over the nodes the user has moved around. */ const handleArrange = useCallback(() => { - setGraph((current) => layoutGraph(current)); - }, []); + setGraph((current) => layoutGraph(current, layoutOptions())); + // The layout starts at the origin, so a canvas left scrolled somewhere + // else would open on empty space right after tidying it up. + canvasRef.current?.scrollTo({ left: 0, top: 0 }); + }, [layoutOptions]); const handleSave = useCallback(() => { // Also write the legacy linear projection when the graph happens to be a @@ -446,7 +482,7 @@ export function ModelBuilderPanel({ throw new Error(t("processing.modelBuilder.importTooLarge")); } setModelName(typeof parsed.name === "string" ? parsed.name : ""); - setGraph(autoLayout(graph)); + setGraph(autoLayout(graph, layoutOptions())); setSelectedNodeId(null); resetRunState(); appendLog(t("processing.modelBuilder.importedLog", { nodes: graph.nodes.length })); @@ -454,7 +490,7 @@ export function ModelBuilderPanel({ appendLog(`${t("processing.modelBuilder.importFailed")}: ${(err as Error).message}`); } }, - [appendLog, confirmDiscard, resetRunState, t], + [appendLog, confirmDiscard, layoutOptions, resetRunState, t], ); // --- Canvas interaction ------------------------------------------------- @@ -852,6 +888,53 @@ export function ModelBuilderPanel({ [paletteWidth, inspectorWidth, maxSideWidth], ); + /** Upper bound for the log pane at the panel's current height. */ + const maxLogHeight = Math.max( + MIN_LOG_HEIGHT, + Math.min(MAX_LOG_HEIGHT, size.height * MAX_LOG_FRACTION), + ); + + /** + * Drag the splitter above the log pane. Dragging up grows it, which is the + * direction that reveals more of the run output. Vertical, so unlike the + * column splitters this needs no writing-direction handling. + */ + const handleLogResizeStart = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const startY = event.clientY; + const start = logHeight; + const handleMove = (move: PointerEvent) => { + setLogHeight(clamp(start - (move.clientY - startY), MIN_LOG_HEIGHT, maxLogHeight)); + }; + const handleEnd = () => { + if (handle.hasPointerCapture(event.pointerId)) + handle.releasePointerCapture(event.pointerId); + handle.removeEventListener("pointermove", handleMove); + handle.removeEventListener("pointerup", handleEnd); + handle.removeEventListener("pointercancel", handleEnd); + }; + handle.addEventListener("pointermove", handleMove); + handle.addEventListener("pointerup", handleEnd); + handle.addEventListener("pointercancel", handleEnd); + }, + [logHeight, maxLogHeight], + ); + + /** Keyboard path for the log splitter. */ + const handleLogResizeKey = useCallback( + (event: ReactKeyboardEvent) => { + const step = event.key === "ArrowUp" ? 16 : event.key === "ArrowDown" ? -16 : 0; + if (!step) return; + event.preventDefault(); + setLogHeight((current) => clamp(current + step, MIN_LOG_HEIGHT, maxLogHeight)); + }, + [maxLogHeight], + ); + /** Keyboard path for the splitters, so a column is resizable without a mouse. */ const handleSideResizeKey = useCallback( (event: ReactKeyboardEvent, side: "palette" | "inspector") => { @@ -1219,6 +1302,16 @@ export function ModelBuilderPanel({ } layers={layers} issues={selectedNode ? (issuesByNode.get(selectedNode.id) ?? []) : []} + keptPorts={ + selectedNode + ? new Set( + (resolveDescriptor(selectedNode.provider, selectedNode.toolId)?.outputs ?? []) + .filter((port) => portFeedsOutput(graph, selectedNode.id, port.id)) + .map((port) => port.id), + ) + : new Set() + } + onKeepResult={(portId) => selectedNode && handleKeepResult(selectedNode.id, portId)} onFieldChange={(field, value) => selectedNode && setGraph((current) => setNodeField(current, selectedNode.id, field, value)) @@ -1277,7 +1370,16 @@ export function ModelBuilderPanel({
{/* Issues + log */} -
+
+
{catalogFailed && (
@@ -1599,16 +1701,21 @@ function NodeInspector({ descriptor, layers, issues, + keptPorts, onFieldChange, onParamChange, + onKeepResult, onRemove, }: { node: ModelGraphNode | null; descriptor: ModelToolDescriptor | undefined; layers: GeoLibreLayer[]; issues: ModelGraphIssue[]; + /** Output ports of this node that already feed an `output` node. */ + keptPorts: Set; onFieldChange: (field: "layerId" | "name", value: string) => void; onParamChange: (paramId: string, value: unknown) => void; + onKeepResult: (portId: string) => void; onRemove: () => void; }): ReactElement { const { t } = useTranslation(); @@ -1703,6 +1810,38 @@ function NodeInspector({ /> )) )} + {/* A model keeps only what an output node is wired to, so a mid-chain + tool's result is computed and discarded unless the user knows to + add a second output node and fan the port out to it. This makes + that one click, per output port. */} + {descriptor.outputs.length > 0 && ( +
+

+ {t("processing.modelBuilder.keepResultHint")} +

+ {descriptor.outputs.map((port) => ( + + ))} +
+ )}
)}
diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 00121afb08..6642281bd8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -4359,12 +4359,18 @@ "resizePanel": "تغيير حجم اللوحة", "resizePalette": "تغيير حجم لوحة الأدوات", "resizeInspector": "تغيير حجم لوحة الخصائص", + "resizeLog": "تغيير حجم سجل الرسائل", "selectNodeHint": "اختر عقدة لتحرير إعداداتها.", "sourceLayer": "طبقة المصدر", "chooseLayer": "اختر طبقة...", "resultName": "اسم النتيجة", "resultNamePlaceholder": "مخرج النموذج", "noParameters": "لا توجد معاملات.", + "keepResultHint": "احتفظ بنتيجة وسيطة بإضافة مخرج لها.", + "keepResultSingle": "الاحتفاظ بهذه النتيجة", + "resultKeptSingle": "هذه النتيجة محفوظة", + "keepResult": "الاحتفاظ بـ«{{port}}»", + "resultKept": "«{{port}}» محفوظ", "outputPlaceholder": "تظهر الرسائل هنا.", "connectCycle": "هذا الاتصال سينشئ حلقة مغلقة.", "connectSameNode": "لا يمكن ربط العقدة بنفسها.", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index e64ff65fb5..afca050df4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -4092,12 +4092,18 @@ "resizePanel": "Bereichsgröße ändern", "resizePalette": "Werkzeugpalette in der Größe ändern", "resizeInspector": "Eigenschaftenbereich in der Größe ändern", + "resizeLog": "Meldungsprotokoll in der Größe ändern", "selectNodeHint": "Wählen Sie einen Knoten, um seine Einstellungen zu bearbeiten.", "sourceLayer": "Quellebene", "chooseLayer": "Ebene wählen ...", "resultName": "Ergebnisname", "resultNamePlaceholder": "Modellausgabe", "noParameters": "Keine Parameter.", + "keepResultHint": "Ein Zwischenergebnis behalten, indem eine Ausgabe dafür ergänzt wird.", + "keepResultSingle": "Dieses Ergebnis behalten", + "resultKeptSingle": "Dieses Ergebnis wird behalten", + "keepResult": "„{{port}}“ behalten", + "resultKept": "„{{port}}“ wird behalten", "outputPlaceholder": "Meldungen erscheinen hier.", "connectCycle": "Diese Verbindung würde eine Schleife erzeugen.", "connectSameNode": "Ein Knoten kann sich nicht mit sich selbst verbinden.", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 38ca66b2f4..0479fdc38f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4102,12 +4102,18 @@ "resizePanel": "Resize panel", "resizePalette": "Resize the tool palette", "resizeInspector": "Resize the properties panel", + "resizeLog": "Resize the message log", "selectNodeHint": "Select a node to edit its settings.", "sourceLayer": "Source layer", "chooseLayer": "Choose a layer...", "resultName": "Result name", "resultNamePlaceholder": "Model output", "noParameters": "No parameters.", + "keepResultHint": "Keep an intermediate result by adding an output for it.", + "keepResultSingle": "Keep this result", + "resultKeptSingle": "This result is kept", + "keepResult": "Keep \"{{port}}\"", + "resultKept": "\"{{port}}\" is kept", "outputPlaceholder": "Messages appear here.", "connectCycle": "That connection would create a loop.", "connectSameNode": "A node cannot connect to itself.", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 99077d89e2..69aa2889ab 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -4092,12 +4092,18 @@ "resizePanel": "Cambiar el tamaño del panel", "resizePalette": "Cambiar el tamaño de la paleta de herramientas", "resizeInspector": "Cambiar el tamaño del panel de propiedades", + "resizeLog": "Cambiar el tamaño del registro de mensajes", "selectNodeHint": "Seleccione un nodo para editar su configuración.", "sourceLayer": "Capa de origen", "chooseLayer": "Elija una capa...", "resultName": "Nombre del resultado", "resultNamePlaceholder": "Salida del modelo", "noParameters": "Sin parámetros.", + "keepResultHint": "Conserva un resultado intermedio añadiéndole una salida.", + "keepResultSingle": "Conservar este resultado", + "resultKeptSingle": "Este resultado se conserva", + "keepResult": "Conservar «{{port}}»", + "resultKept": "«{{port}}» se conserva", "outputPlaceholder": "Los mensajes aparecen aquí.", "connectCycle": "Esa conexión crearía un bucle.", "connectSameNode": "Un nodo no puede conectarse consigo mismo.", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 432ba4415e..8f66634964 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -4092,12 +4092,18 @@ "resizePanel": "تغییر اندازهٔ پنل", "resizePalette": "تغییر اندازهٔ پالت ابزارها", "resizeInspector": "تغییر اندازهٔ پنل ویژگی‌ها", + "resizeLog": "تغییر اندازهٔ گزارش پیام‌ها", "selectNodeHint": "برای ویرایش تنظیمات، یک گره را انتخاب کنید.", "sourceLayer": "لایهٔ مبدأ", "chooseLayer": "یک لایه انتخاب کنید...", "resultName": "نام نتیجه", "resultNamePlaceholder": "خروجی مدل", "noParameters": "پارامتری وجود ندارد.", + "keepResultHint": "با افزودن یک خروجی، نتیجهٔ میانی را نگه دارید.", + "keepResultSingle": "نگه‌داشتن این نتیجه", + "resultKeptSingle": "این نتیجه نگه داشته می‌شود", + "keepResult": "نگه‌داشتن «{{port}}»", + "resultKept": "«{{port}}» نگه داشته می‌شود", "outputPlaceholder": "پیام‌ها اینجا نمایش داده می‌شوند.", "connectCycle": "این اتصال یک حلقه ایجاد می‌کند.", "connectSameNode": "یک گره نمی‌تواند به خودش وصل شود.", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 77595ac200..8cb8a0b555 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -4092,12 +4092,18 @@ "resizePanel": "Redimensionner le panneau", "resizePalette": "Redimensionner la palette d'outils", "resizeInspector": "Redimensionner le panneau des propriétés", + "resizeLog": "Redimensionner le journal des messages", "selectNodeHint": "Sélectionnez un nœud pour modifier ses paramètres.", "sourceLayer": "Couche source", "chooseLayer": "Choisir une couche...", "resultName": "Nom du résultat", "resultNamePlaceholder": "Sortie du modèle", "noParameters": "Aucun paramètre.", + "keepResultHint": "Conservez un résultat intermédiaire en lui ajoutant une sortie.", + "keepResultSingle": "Conserver ce résultat", + "resultKeptSingle": "Ce résultat est conservé", + "keepResult": "Conserver « {{port}} »", + "resultKept": "« {{port}} » est conservé", "outputPlaceholder": "Les messages apparaissent ici.", "connectCycle": "Cette connexion créerait une boucle.", "connectSameNode": "Un nœud ne peut pas se connecter à lui-même.", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 28bbb01f78..6e2df2270a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -4092,12 +4092,18 @@ "resizePanel": "पैनल का आकार बदलें", "resizePalette": "उपकरण पैलेट का आकार बदलें", "resizeInspector": "गुण पैनल का आकार बदलें", + "resizeLog": "संदेश लॉग का आकार बदलें", "selectNodeHint": "सेटिंग्स संपादित करने के लिए कोई नोड चुनें।", "sourceLayer": "स्रोत परत", "chooseLayer": "एक परत चुनें...", "resultName": "परिणाम नाम", "resultNamePlaceholder": "मॉडल आउटपुट", "noParameters": "कोई पैरामीटर नहीं।", + "keepResultHint": "किसी मध्यवर्ती परिणाम के लिए आउटपुट जोड़कर उसे सहेजें।", + "keepResultSingle": "यह परिणाम सहेजें", + "resultKeptSingle": "यह परिणाम सहेजा गया है", + "keepResult": "\"{{port}}\" सहेजें", + "resultKept": "\"{{port}}\" सहेजा गया है", "outputPlaceholder": "संदेश यहाँ दिखाई देंगे।", "connectCycle": "वह कनेक्शन एक लूप बना देगा।", "connectSameNode": "कोई नोड स्वयं से नहीं जुड़ सकता।", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index c3caa065b3..3c0b18b2bc 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -4025,12 +4025,18 @@ "resizePanel": "Ubah ukuran panel", "resizePalette": "Ubah ukuran palet alat", "resizeInspector": "Ubah ukuran panel properti", + "resizeLog": "Ubah ukuran log pesan", "selectNodeHint": "Pilih sebuah simpul untuk mengubah pengaturannya.", "sourceLayer": "Lapisan sumber", "chooseLayer": "Pilih lapisan...", "resultName": "Nama hasil", "resultNamePlaceholder": "Keluaran model", "noParameters": "Tidak ada parameter.", + "keepResultHint": "Simpan hasil antara dengan menambahkan keluaran untuknya.", + "keepResultSingle": "Simpan hasil ini", + "resultKeptSingle": "Hasil ini disimpan", + "keepResult": "Simpan \"{{port}}\"", + "resultKept": "\"{{port}}\" disimpan", "outputPlaceholder": "Pesan muncul di sini.", "connectCycle": "Koneksi itu akan membuat perulangan.", "connectSameNode": "Simpul tidak dapat terhubung ke dirinya sendiri.", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 1a56fe9e77..8cfe8e7ac7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -4092,12 +4092,18 @@ "resizePanel": "Ridimensiona il pannello", "resizePalette": "Ridimensiona la tavolozza degli strumenti", "resizeInspector": "Ridimensiona il pannello delle proprietà", + "resizeLog": "Ridimensiona il registro dei messaggi", "selectNodeHint": "Seleziona un nodo per modificarne le impostazioni.", "sourceLayer": "Livello di origine", "chooseLayer": "Scegli un livello...", "resultName": "Nome del risultato", "resultNamePlaceholder": "Uscita del modello", "noParameters": "Nessun parametro.", + "keepResultHint": "Conserva un risultato intermedio aggiungendogli un'uscita.", + "keepResultSingle": "Conserva questo risultato", + "resultKeptSingle": "Questo risultato viene conservato", + "keepResult": "Conserva «{{port}}»", + "resultKept": "«{{port}}» viene conservato", "outputPlaceholder": "I messaggi compaiono qui.", "connectCycle": "Quel collegamento creerebbe un ciclo.", "connectSameNode": "Un nodo non può collegarsi a se stesso.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 394a72137c..037f914758 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -4025,12 +4025,18 @@ "resizePanel": "パネルのサイズを変更", "resizePalette": "ツールパレットのサイズを変更", "resizeInspector": "プロパティパネルのサイズを変更", + "resizeLog": "メッセージログのサイズを変更", "selectNodeHint": "ノードを選択すると設定を編集できます。", "sourceLayer": "ソースレイヤー", "chooseLayer": "レイヤーを選択…", "resultName": "結果名", "resultNamePlaceholder": "モデル出力", "noParameters": "パラメータはありません。", + "keepResultHint": "出力を追加すると、途中の結果も残せます。", + "keepResultSingle": "この結果を残す", + "resultKeptSingle": "この結果は残されます", + "keepResult": "「{{port}}」を残す", + "resultKept": "「{{port}}」は残されます", "outputPlaceholder": "メッセージはここに表示されます。", "connectCycle": "その接続はループを作成します。", "connectSameNode": "ノードを自分自身に接続することはできません。", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index e55ae0fb36..62e3280de8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -4092,12 +4092,18 @@ "resizePanel": "პანელის ზომის შეცვლა", "resizePalette": "ხელსაწყოთა პალიტრის ზომის შეცვლა", "resizeInspector": "თვისებების პანელის ზომის შეცვლა", + "resizeLog": "შეტყობინებების ჟურნალის ზომის შეცვლა", "selectNodeHint": "აირჩიეთ კვანძი მისი პარამეტრების შესაცვლელად.", "sourceLayer": "წყაროს ფენა", "chooseLayer": "აირჩიეთ ფენა...", "resultName": "შედეგის სახელი", "resultNamePlaceholder": "მოდელის გამომავალი", "noParameters": "პარამეტრები არ არის.", + "keepResultHint": "შუალედური შედეგის შესანარჩუნებლად დაამატეთ მისთვის გამოსავალი.", + "keepResultSingle": "ამ შედეგის შენარჩუნება", + "resultKeptSingle": "ეს შედეგი შენარჩუნებულია", + "keepResult": "„{{port}}“-ის შენარჩუნება", + "resultKept": "„{{port}}“ შენარჩუნებულია", "outputPlaceholder": "შეტყობინებები აქ გამოჩნდება.", "connectCycle": "ეს კავშირი მარყუჟს შექმნის.", "connectSameNode": "კვანძი საკუთარ თავს ვერ დაუკავშირდება.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 4a1a495fa2..e71d083271 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -4025,12 +4025,18 @@ "resizePanel": "패널 크기 조정", "resizePalette": "도구 팔레트 크기 조정", "resizeInspector": "속성 패널 크기 조정", + "resizeLog": "메시지 로그 크기 조정", "selectNodeHint": "노드를 선택하면 설정을 편집할 수 있습니다.", "sourceLayer": "원본 레이어", "chooseLayer": "레이어 선택...", "resultName": "결과 이름", "resultNamePlaceholder": "모델 출력", "noParameters": "매개변수가 없습니다.", + "keepResultHint": "출력을 추가하면 중간 결과도 남길 수 있습니다.", + "keepResultSingle": "이 결과 남기기", + "resultKeptSingle": "이 결과를 남김", + "keepResult": "\"{{port}}\" 남기기", + "resultKept": "\"{{port}}\" 남김", "outputPlaceholder": "메시지가 여기에 표시됩니다.", "connectCycle": "그 연결은 순환을 만듭니다.", "connectSameNode": "노드는 자기 자신에 연결할 수 없습니다.", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index f025751546..234b0126d4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -4092,12 +4092,18 @@ "resizePanel": "Paneelgrootte wijzigen", "resizePalette": "Grootte van het gereedschapspalet wijzigen", "resizeInspector": "Grootte van het eigenschappenpaneel wijzigen", + "resizeLog": "Grootte van het berichtenlogboek wijzigen", "selectNodeHint": "Selecteer een knooppunt om de instellingen te bewerken.", "sourceLayer": "Bronlaag", "chooseLayer": "Kies een laag...", "resultName": "Resultaatnaam", "resultNamePlaceholder": "Modeluitvoer", "noParameters": "Geen parameters.", + "keepResultHint": "Bewaar een tussenresultaat door er een uitvoer aan toe te voegen.", + "keepResultSingle": "Dit resultaat bewaren", + "resultKeptSingle": "Dit resultaat wordt bewaard", + "keepResult": "“{{port}}” bewaren", + "resultKept": "“{{port}}” wordt bewaard", "outputPlaceholder": "Berichten verschijnen hier.", "connectCycle": "Die verbinding zou een lus maken.", "connectSameNode": "Een knooppunt kan niet met zichzelf verbinden.", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index 3b15c1df3b..ae258832a4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -4092,12 +4092,18 @@ "resizePanel": "Redimensionar painel", "resizePalette": "Redimensionar a paleta de ferramentas", "resizeInspector": "Redimensionar o painel de propriedades", + "resizeLog": "Redimensionar o registo de mensagens", "selectNodeHint": "Selecione um nó para editar as suas definições.", "sourceLayer": "Camada de origem", "chooseLayer": "Escolher uma camada...", "resultName": "Nome do resultado", "resultNamePlaceholder": "Saída do modelo", "noParameters": "Sem parâmetros.", + "keepResultHint": "Guarde um resultado intermédio adicionando-lhe uma saída.", + "keepResultSingle": "Guardar este resultado", + "resultKeptSingle": "Este resultado é guardado", + "keepResult": "Guardar «{{port}}»", + "resultKept": "«{{port}}» é guardado", "outputPlaceholder": "As mensagens aparecem aqui.", "connectCycle": "Essa ligação criaria um ciclo.", "connectSameNode": "Um nó não pode ligar-se a si próprio.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 099c1dca49..de239898da 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -4226,12 +4226,18 @@ "resizePanel": "Изменить размер панели", "resizePalette": "Изменить размер палитры инструментов", "resizeInspector": "Изменить размер панели свойств", + "resizeLog": "Изменить размер журнала сообщений", "selectNodeHint": "Выберите узел, чтобы изменить его настройки.", "sourceLayer": "Исходный слой", "chooseLayer": "Выберите слой...", "resultName": "Имя результата", "resultNamePlaceholder": "Вывод модели", "noParameters": "Нет параметров.", + "keepResultHint": "Сохраните промежуточный результат, добавив для него выход.", + "keepResultSingle": "Сохранить этот результат", + "resultKeptSingle": "Этот результат сохраняется", + "keepResult": "Сохранить «{{port}}»", + "resultKept": "«{{port}}» сохраняется", "outputPlaceholder": "Здесь появятся сообщения.", "connectCycle": "Эта связь создаст цикл.", "connectSameNode": "Узел не может быть связан сам с собой.", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index ba66b73895..534d7c0d97 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -4025,12 +4025,18 @@ "resizePanel": "ปรับขนาดแผง", "resizePalette": "ปรับขนาดแผงเครื่องมือ", "resizeInspector": "ปรับขนาดแผงคุณสมบัติ", + "resizeLog": "ปรับขนาดบันทึกข้อความ", "selectNodeHint": "เลือกโหนดเพื่อแก้ไขการตั้งค่า", "sourceLayer": "ชั้นข้อมูลต้นทาง", "chooseLayer": "เลือกชั้นข้อมูล...", "resultName": "ชื่อผลลัพธ์", "resultNamePlaceholder": "เอาต์พุตของแบบจำลอง", "noParameters": "ไม่มีพารามิเตอร์", + "keepResultHint": "เก็บผลลัพธ์ระหว่างทางได้ด้วยการเพิ่มเอาต์พุตให้กับมัน", + "keepResultSingle": "เก็บผลลัพธ์นี้", + "resultKeptSingle": "เก็บผลลัพธ์นี้ไว้แล้ว", + "keepResult": "เก็บ \"{{port}}\"", + "resultKept": "เก็บ \"{{port}}\" ไว้แล้ว", "outputPlaceholder": "ข้อความจะปรากฏที่นี่", "connectCycle": "การเชื่อมต่อนั้นจะทำให้เกิดวงวน", "connectSameNode": "โหนดไม่สามารถเชื่อมต่อกับตัวเองได้", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 3294b8e867..607be37862 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -4092,12 +4092,18 @@ "resizePanel": "Paneli yeniden boyutlandır", "resizePalette": "Araç paletini yeniden boyutlandır", "resizeInspector": "Özellikler panelini yeniden boyutlandır", + "resizeLog": "İleti günlüğünü yeniden boyutlandır", "selectNodeHint": "Ayarlarını düzenlemek için bir düğüm seçin.", "sourceLayer": "Kaynak katman", "chooseLayer": "Bir katman seçin...", "resultName": "Sonuç adı", "resultNamePlaceholder": "Model çıktısı", "noParameters": "Parametre yok.", + "keepResultHint": "Bir ara sonucu, ona bir çıktı ekleyerek saklayın.", + "keepResultSingle": "Bu sonuç saklansın", + "resultKeptSingle": "Bu sonuç saklanıyor", + "keepResult": "\"{{port}}\" saklansın", + "resultKept": "\"{{port}}\" saklanıyor", "outputPlaceholder": "İletiler burada görünür.", "connectCycle": "Bu bağlantı bir döngü oluşturur.", "connectSameNode": "Bir düğüm kendisine bağlanamaz.", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index bb07f7c304..8b13a952ee 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -4111,12 +4111,18 @@ "resizePanel": "Đổi kích thước bảng", "resizePalette": "Đổi kích thước bảng công cụ", "resizeInspector": "Đổi kích thước bảng thuộc tính", + "resizeLog": "Đổi kích thước nhật ký thông báo", "selectNodeHint": "Chọn một nút để chỉnh sửa thiết lập của nó.", "sourceLayer": "Lớp nguồn", "chooseLayer": "Chọn một lớp...", "resultName": "Tên kết quả", "resultNamePlaceholder": "Đầu ra mô hình", "noParameters": "Không có tham số.", + "keepResultHint": "Giữ lại kết quả trung gian bằng cách thêm một đầu ra cho nó.", + "keepResultSingle": "Giữ kết quả này", + "resultKeptSingle": "Kết quả này được giữ lại", + "keepResult": "Giữ \"{{port}}\"", + "resultKept": "\"{{port}}\" được giữ lại", "outputPlaceholder": "Thông báo sẽ hiển thị ở đây.", "connectCycle": "Kết nối đó sẽ tạo thành vòng lặp.", "connectSameNode": "Một nút không thể tự nối với chính nó.", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 1647ae619d..bf686c2b50 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -4025,12 +4025,18 @@ "resizePanel": "调整面板大小", "resizePalette": "调整工具面板大小", "resizeInspector": "调整属性面板大小", + "resizeLog": "调整消息日志大小", "selectNodeHint": "选择一个节点以编辑其设置。", "sourceLayer": "源图层", "chooseLayer": "选择图层…", "resultName": "结果名称", "resultNamePlaceholder": "模型输出", "noParameters": "无参数。", + "keepResultHint": "为中间结果添加一个输出即可保留它。", + "keepResultSingle": "保留此结果", + "resultKeptSingle": "此结果已保留", + "keepResult": "保留“{{port}}”", + "resultKept": "“{{port}}”已保留", "outputPlaceholder": "消息将显示在此处。", "connectCycle": "该连接会形成环路。", "connectSameNode": "节点不能连接到自身。", diff --git a/apps/geolibre-desktop/src/lib/model-graph-edit.ts b/apps/geolibre-desktop/src/lib/model-graph-edit.ts index 94a7d14597..6bef353efb 100644 --- a/apps/geolibre-desktop/src/lib/model-graph-edit.ts +++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts @@ -5,7 +5,7 @@ import type { ModelToolProvider, ProcessingModelGraph, } from "@geolibre/core"; -import type { ModelToolDescriptor } from "@geolibre/processing"; +import { OUTPUT_NODE_PORT, type ModelToolDescriptor } from "@geolibre/processing"; /** An empty canvas. */ export function emptyModelGraph(): ProcessingModelGraph { @@ -271,23 +271,54 @@ export function createsCycle(graph: ProcessingModelGraph, from: string, to: stri * @param graph The imported graph. * @returns The graph, with positions filled in only if they were all at 0,0. */ -export function autoLayout(graph: ProcessingModelGraph): ProcessingModelGraph { +export function autoLayout( + graph: ProcessingModelGraph, + options: LayoutOptions = {}, +): ProcessingModelGraph { const placed = graph.nodes.some((node) => node.x !== 0 || node.y !== 0); if (placed) return graph; - return layoutGraph(graph); + return layoutGraph(graph, options); +} + +/** How much room the layout has to work with. */ +export interface LayoutOptions { + /** + * Visible canvas width in pixels. The flow wraps to a new band once a depth + * would not fit, so a long chain stays reachable instead of running off the + * right edge. Omitted (or non-finite) means unlimited width: one band, the + * old single-row behaviour. + */ + width?: number; } +/** Horizontal pitch between two consecutive depths. */ +const LAYOUT_COLUMN = 240; +/** Vertical pitch between two nodes sharing a depth. */ +const LAYOUT_ROW = 120; +/** Padding between the canvas origin and the first node. */ +const LAYOUT_MARGIN = 40; + /** - * Arrange every node on a left-to-right grid by its depth from the sources, - * discarding the positions it already had. + * Arrange every node by its depth from the sources, discarding the positions + * it already had. + * + * The flow reads left to right, and wraps: when `options.width` cannot fit + * another depth, the next one starts a fresh band below the deepest node of + * the current one. Without that a chain of more than a few tools ran straight + * off the right edge of the canvas, so Arrange pushed work out of view rather + * than tidying it into view. * * @param graph The graph to lay out. + * @param options Room available; see {@link LayoutOptions}. * @returns The graph with every node repositioned. */ -export function layoutGraph(graph: ProcessingModelGraph): ProcessingModelGraph { +export function layoutGraph( + graph: ProcessingModelGraph, + options: LayoutOptions = {}, +): ProcessingModelGraph { if (graph.nodes.length === 0) return graph; - const COLUMN = 240; - const ROW = 120; + const COLUMN = LAYOUT_COLUMN; + const ROW = LAYOUT_ROW; // Depth from the sources, so the layout reads left-to-right along the flow. const depth = new Map(); const incoming = new Map(); @@ -330,14 +361,47 @@ export function layoutGraph(graph: ProcessingModelGraph): ProcessingModelGraph { return depth.get(start) ?? 0; }; for (const node of graph.nodes) resolveDepth(node.id); + + // How many depths fit side by side. The last one has to fit whole, not just + // start inside the viewport, or the rightmost card is still clipped. + const usable = options.width; + const perBand = + usable && Number.isFinite(usable) + ? Math.max(1, Math.floor((usable - LAYOUT_MARGIN - NODE_WIDTH) / COLUMN) + 1) + : Number.POSITIVE_INFINITY; + + // Each band is as tall as its most crowded depth, so bands never overlap. + const perDepth = new Map(); + for (const node of graph.nodes) { + const value = depth.get(node.id) ?? 0; + perDepth.set(value, (perDepth.get(value) ?? 0) + 1); + } + const bandRows = new Map(); + for (const [value, count] of perDepth) { + const band = Number.isFinite(perBand) ? Math.floor(value / perBand) : 0; + bandRows.set(band, Math.max(bandRows.get(band) ?? 0, count)); + } + const bandTop = new Map(); + let top = LAYOUT_MARGIN; + for (const band of [...bandRows.keys()].sort((a, b) => a - b)) { + bandTop.set(band, top); + top += (bandRows.get(band) ?? 1) * ROW; + } + const perColumn = new Map(); return { ...graph, nodes: graph.nodes.map((node) => { - const column = depth.get(node.id) ?? 0; - const row = perColumn.get(column) ?? 0; - perColumn.set(column, row + 1); - return { ...node, x: 40 + column * COLUMN, y: 40 + row * ROW }; + const value = depth.get(node.id) ?? 0; + const band = Number.isFinite(perBand) ? Math.floor(value / perBand) : 0; + const column = Number.isFinite(perBand) ? value % perBand : value; + const row = perColumn.get(value) ?? 0; + perColumn.set(value, row + 1); + return { + ...node, + x: LAYOUT_MARGIN + column * COLUMN, + y: (bandTop.get(band) ?? LAYOUT_MARGIN) + row * ROW, + }; }), }; } @@ -384,3 +448,61 @@ export function graphsEqual(a: ProcessingModelGraph, b: ProcessingModelGraph): b `${graph.nodes.map(stableKey).sort().join("|")}#${graph.edges.map(stableKey).sort().join("|")}`; return canonical(a) === canonical(b); } + +/** + * Attach a fresh `output` node to one of a tool's output ports. + * + * A model keeps only what an `output` node is wired to, so without this the + * only reachable result is the end of the chain — every intermediate step is + * computed and thrown away. An output port may feed the next tool *and* an + * output node at the same time, so keeping a step costs nothing downstream. + * + * @param graph The current graph. + * @param nodeId The tool whose result should be kept. + * @param portId The output port to tap. + * @param createId Fresh id source. + * @returns The updated graph and the new node's id, or `null` when the tool is + * not in the graph. + */ +export function addOutputForPort( + graph: ProcessingModelGraph, + nodeId: string, + portId: string, + createId: () => string, +): { graph: ProcessingModelGraph; nodeId: string } | null { + const source = graph.nodes.find((node) => node.id === nodeId); + if (!source) return null; + // One column to the right of the tool, where the flow already reads; the + // placement helper pushes it down until it has a clear footprint. + const added = addDataNode( + graph, + "output", + { x: source.x + NODE_WIDTH + 72, y: source.y }, + createId, + ); + const connected = connectNodes( + added.graph, + { nodeId, portId }, + { nodeId: added.nodeId, portId: OUTPUT_NODE_PORT }, + createId, + ); + // A brand-new output node cannot close a loop or target itself, so a + // rejection here is not reachable; fall back to the unwired node rather than + // dropping the user's click on the floor. + if ("rejected" in connected) return { graph: added.graph, nodeId: added.nodeId }; + return { graph: connected.graph, nodeId: added.nodeId }; +} + +/** True when this output port already feeds an `output` node. */ +export function portFeedsOutput( + graph: ProcessingModelGraph, + nodeId: string, + portId: string, +): boolean { + const outputs = new Set( + graph.nodes.filter((node) => node.kind === "output").map((node) => node.id), + ); + return graph.edges.some( + (edge) => edge.from === nodeId && edge.fromPort === portId && outputs.has(edge.to), + ); +} diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts index 9895542619..664cbedb24 100644 --- a/tests/model-graph-edit.test.ts +++ b/tests/model-graph-edit.test.ts @@ -4,6 +4,7 @@ import type { ProcessingModelGraph } from "../packages/core/src/types"; import type { ModelToolDescriptor } from "../packages/processing/src/model-graph"; import { addDataNode, + addOutputForPort, addToolNode, autoLayout, connectNodes, @@ -12,6 +13,7 @@ import { graphsEqual, layoutGraph, moveNode, + portFeedsOutput, removeEdge, removeNode, setNodeField, @@ -360,6 +362,77 @@ describe("auto layout", () => { const graph: ProcessingModelGraph = { nodes: [], edges: [] }; assert.deepEqual(layoutGraph(graph), graph); }); + + /** A chain of `n` tool nodes, each fed by the one before it. */ + const chainOf = (n: number): ProcessingModelGraph => ({ + nodes: Array.from({ length: n }, (_, i) => ({ + id: `n${i}`, + kind: "tool" as const, + x: 0, + y: 0, + provider: "vector" as const, + toolId: "buffer", + })), + edges: Array.from({ length: n - 1 }, (_, i) => ({ + id: `e${i}`, + from: `n${i}`, + fromPort: "out", + to: `n${i + 1}`, + toPort: "layer", + })), + }); + + it("wraps a long chain into bands that fit the canvas width", () => { + // 640px fits depths at x=40 and x=280 (each card is NODE_WIDTH wide), so a + // six-long chain has to wrap rather than run off the right edge. + const laid = layoutGraph(chainOf(6), { width: 640 }); + const at = Object.fromEntries(laid.nodes.map((node) => [node.id, [node.x, node.y]])); + const widest = Math.max(...laid.nodes.map((node) => node.x + NODE_WIDTH)); + assert.ok(widest <= 640, `rightmost edge ${widest} should fit in 640`); + // Reads left to right, then wraps down to a fresh band. + assert.equal(at.n0[1], at.n1[1], "first two share a band"); + assert.ok(at.n1[0] > at.n0[0], "and run left to right within it"); + assert.ok(at.n2[1] > at.n1[1], "the third wraps to the next band down"); + assert.equal(at.n2[0], at.n0[0], "starting back at the left margin"); + }); + + it("keeps one band when no width is given", () => { + const laid = layoutGraph(chainOf(6)); + const ys = new Set(laid.nodes.map((node) => node.y)); + assert.equal(ys.size, 1, "every node stays on one row"); + const xs = laid.nodes.map((node) => node.x).sort((a, b) => a - b); + assert.equal(new Set(xs).size, 6, "each depth gets its own column"); + }); + + it("still places a single column when the canvas is narrower than one card", () => { + const laid = layoutGraph(chainOf(3), { width: 50 }); + assert.equal(new Set(laid.nodes.map((node) => node.x)).size, 1); + assert.equal(new Set(laid.nodes.map((node) => node.y)).size, 3); + }); + + it("gives a band enough height for its most crowded depth", () => { + // Two sources feed one tool: depth 0 holds two nodes, so the next band has + // to clear both rather than overlapping the second. + const graph: ProcessingModelGraph = { + nodes: [ + { id: "a", kind: "input", x: 0, y: 0, layerId: "one" }, + { id: "b", kind: "input", x: 0, y: 0, layerId: "two" }, + { id: "c", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "clip" }, + { id: "d", kind: "output", x: 0, y: 0, name: "Out" }, + ], + edges: [ + { id: "e1", from: "a", fromPort: "out", to: "c", toPort: "layer" }, + { id: "e2", from: "b", fromPort: "out", to: "c", toPort: "overlay" }, + { id: "e3", from: "c", fromPort: "out", to: "d", toPort: "in" }, + ], + }; + // One depth per band, so each of the three depths starts its own band. + const laid = layoutGraph(graph, { width: 260 }); + const at = Object.fromEntries(laid.nodes.map((node) => [node.id, node.y])); + assert.notEqual(at.a, at.b, "the two sources stack within their band"); + assert.ok(at.c >= Math.max(at.a, at.b) + NODE_HEIGHT, "and the next band clears both"); + assert.ok(at.d > at.c); + }); }); describe("graphsEqual", () => { @@ -435,3 +508,58 @@ describe("graphsEqual", () => { assert.equal(graphsEqual(emptyModelGraph(), emptyModelGraph()), true); }); }); + +describe("keeping an intermediate result", () => { + const chain = (): ProcessingModelGraph => ({ + nodes: [ + { id: "in", kind: "input", x: 0, y: 0, layerId: "roads" }, + { id: "t1", kind: "tool", x: 240, y: 0, provider: "vector", toolId: "buffer" }, + { id: "t2", kind: "tool", x: 480, y: 0, provider: "vector", toolId: "centroids" }, + { id: "out", kind: "output", x: 720, y: 0, name: "Final" }, + ], + edges: [ + { id: "e1", from: "in", fromPort: "out", to: "t1", toPort: "layer" }, + { id: "e2", from: "t1", fromPort: "out", to: "t2", toPort: "layer" }, + { id: "e3", from: "t2", fromPort: "out", to: "out", toPort: "in" }, + ], + }); + let seq = 0; + const ids = () => `gen${seq++}`; + + it("adds an output node wired to the tool's port", () => { + const result = addOutputForPort(chain(), "t1", "out", ids); + assert.ok(result); + const added = result.graph.nodes.find((node) => node.id === result.nodeId); + assert.equal(added?.kind, "output"); + assert.ok( + result.graph.edges.some( + (edge) => edge.from === "t1" && edge.fromPort === "out" && edge.to === result.nodeId, + ), + ); + }); + + it("leaves the port still feeding the next tool", () => { + // Fanning out must not cost the chain its downstream link, or "keep this + // result" would quietly truncate the model. + const result = addOutputForPort(chain(), "t1", "out", ids); + assert.ok(result); + assert.ok(result.graph.edges.some((edge) => edge.from === "t1" && edge.to === "t2")); + }); + + it("returns null for a node that is not in the graph", () => { + assert.equal(addOutputForPort(chain(), "ghost", "out", ids), null); + }); + + it("reports whether a port already feeds an output node", () => { + const graph = chain(); + assert.equal(portFeedsOutput(graph, "t2", "out"), true, "the final tool is kept"); + assert.equal(portFeedsOutput(graph, "t1", "out"), false, "the middle one is not"); + const result = addOutputForPort(graph, "t1", "out", ids); + assert.ok(result); + assert.equal(portFeedsOutput(result.graph, "t1", "out"), true); + }); + + it("does not count a port that only feeds another tool", () => { + assert.equal(portFeedsOutput(chain(), "in", "out"), false); + }); +}); From 1683f266394a86c0ae80fa112e8ab03e1bad190a Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:43:22 -0400 Subject: [PATCH 20/22] Name kept results after their tool, and let the panel minimize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Keep this result" left the new output node unnamed, and an unnamed output falls back to a single shared "Model output" label — so a model that kept two steps put two identically named layers on the map. The output now takes the tool's display name ("Buffer", "Centroids"), with the port appended when a tool has more than one output since its two results are not the same thing. uniqueOutputName counts up against the outputs already in the graph, so two Buffer steps become "Buffer" and "Buffer 2" rather than colliding again. The panel also gains a minimize toggle beside Close, collapsing it to just its title bar so the map underneath can be read without closing the panel and losing the model on the canvas. A run keeps going while collapsed, which is the point: it is for watching results land. Verified in a browser against a dropped GeoJSON: a model keeping both Buffer and Centroids runs to "2 output(s) added" and puts layers named "Buffer" and "Centroids" on the map; the button reads back "This result is kept" and disables once a port is kept; minimize takes the panel 560px -> 43px with the map fully visible, and restoring brings back all five nodes. --- .../model-builder/ModelBuilderPanel.tsx | 590 ++++++++++-------- .../geolibre-desktop/src/i18n/locales/ar.json | 2 + .../geolibre-desktop/src/i18n/locales/de.json | 2 + .../geolibre-desktop/src/i18n/locales/en.json | 2 + .../geolibre-desktop/src/i18n/locales/es.json | 2 + .../geolibre-desktop/src/i18n/locales/fa.json | 2 + .../geolibre-desktop/src/i18n/locales/fr.json | 2 + .../geolibre-desktop/src/i18n/locales/hi.json | 2 + .../geolibre-desktop/src/i18n/locales/id.json | 2 + .../geolibre-desktop/src/i18n/locales/it.json | 2 + .../geolibre-desktop/src/i18n/locales/ja.json | 2 + .../geolibre-desktop/src/i18n/locales/ka.json | 2 + .../geolibre-desktop/src/i18n/locales/ko.json | 2 + .../geolibre-desktop/src/i18n/locales/nl.json | 2 + .../geolibre-desktop/src/i18n/locales/pt.json | 2 + .../geolibre-desktop/src/i18n/locales/ru.json | 2 + .../geolibre-desktop/src/i18n/locales/th.json | 2 + .../geolibre-desktop/src/i18n/locales/tr.json | 2 + .../geolibre-desktop/src/i18n/locales/vi.json | 2 + .../geolibre-desktop/src/i18n/locales/zh.json | 2 + .../src/lib/model-graph-edit.ts | 41 +- tests/model-graph-edit.test.ts | 51 ++ 22 files changed, 447 insertions(+), 273 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 9920fbd9bf..259ad28b1d 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -29,6 +29,8 @@ import { } from "@geolibre/processing"; import { Button, Input, Label, ScrollArea, Select, cn } from "@geolibre/ui"; import { + ChevronDown, + ChevronUp, Download, GripVertical, LayoutGrid, @@ -175,6 +177,12 @@ export function ModelBuilderPanel({ const [paletteWidth, setPaletteWidth] = useState(DEFAULT_PALETTE_WIDTH); const [inspectorWidth, setInspectorWidth] = useState(DEFAULT_INSPECTOR_WIDTH); const [logHeight, setLogHeight] = useState(DEFAULT_LOG_HEIGHT); + /** + * Collapsed to just the title bar, so the map underneath can be read without + * closing the panel and losing the model on the canvas. A run keeps going + * while collapsed — the point is to watch its results land. + */ + const [minimized, setMinimized] = useState(false); const [modelId, setModelId] = useState(() => createId()); const [modelName, setModelName] = useState(""); const [graph, setGraph] = useState(emptyModelGraph); @@ -399,9 +407,9 @@ export function ModelBuilderPanel({ * The port can still feed the next tool as well, so keeping an intermediate * step costs nothing downstream. */ - const handleKeepResult = useCallback((nodeId: string, portId: string) => { + const handleKeepResult = useCallback((nodeId: string, portId: string, name: string) => { setGraph((current) => { - const next = addOutputForPort(current, nodeId, portId, createId); + const next = addOutputForPort(current, nodeId, portId, createId, name); return next ? next.graph : current; }); }, []); @@ -1034,7 +1042,9 @@ export function ModelBuilderPanel({ left: position.x, top: position.y, width: size.width, - height: size.height, + // Collapsed, the section shrinks to whatever the title bar needs + // rather than painting a card-coloured rectangle over the map. + height: minimized ? "auto" : size.height, } as CSSProperties } > @@ -1113,6 +1123,20 @@ export function ModelBuilderPanel({ {t("processing.modelBuilder.runModel")} )} +
-
- {/* Palette */} -
-
- setSearch(event.target.value)} - placeholder={t("processing.modelBuilder.searchTools")} - aria-label={t("processing.modelBuilder.searchTools")} - className="h-7 text-xs" - /> -
- - + {!minimized && ( +
+ {/* Palette */} +
+
+ setSearch(event.target.value)} + placeholder={t("processing.modelBuilder.searchTools")} + aria-label={t("processing.modelBuilder.searchTools")} + className="h-7 text-xs" + /> +
+ + +
+ + {catalog.length === 0 ? ( +

+ {t("processing.modelBuilder.loadingTools")} +

+ ) : groups.length === 0 ? ( +

+ {t("processing.modelBuilder.noToolsMatch")} +

+ ) : ( + groups.map((group) => ( +
+

+ {group.group} +

+ {group.tools.map((tool) => ( + // A real button, not a bare draggable div: dragging is the + // only other way to add a tool node, so a div here would put + // the panel's core interaction out of reach of the keyboard + // entirely. Activating it drops the node onto the canvas. + + ))} +
+ )) + )} +
- - {catalog.length === 0 ? ( -

- {t("processing.modelBuilder.loadingTools")} -

- ) : groups.length === 0 ? ( -

- {t("processing.modelBuilder.noToolsMatch")} +

handleSideResizeStart(event, "palette")} + onKeyDown={(event) => handleSideResizeKey(event, "palette")} + className="w-1 shrink-0 cursor-col-resize bg-border/60 hover:bg-primary/60 focus-visible:bg-primary focus-visible:outline-none" + /> + + {/* Canvas */} +
{ + if (event.dataTransfer.types.includes(TOOL_DRAG_TYPE)) { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={handleCanvasDrop} + onClick={(event) => { + if (event.target === event.currentTarget) setSelectedNodeId(null); + }} + > +
+ setGraph((current) => removeEdge(current, edgeId))} + /> + {graph.nodes.map((node) => ( + + ))} +
+ {/* Centred on the visible canvas rather than the scroll extent, which + is wider than the viewport and would push the hint out of sight. */} + {graph.nodes.length === 0 && ( +

+ {t("processing.modelBuilder.canvasEmpty")}

- ) : ( - groups.map((group) => ( -
-

- {group.group} -

- {group.tools.map((tool) => ( - // A real button, not a bare draggable div: dragging is the - // only other way to add a tool node, so a div here would put - // the panel's core interaction out of reach of the keyboard - // entirely. Activating it drops the node onto the canvas. - - ))} -
- )) )} - -
-
handleSideResizeStart(event, "palette")} - onKeyDown={(event) => handleSideResizeKey(event, "palette")} - className="w-1 shrink-0 cursor-col-resize bg-border/60 hover:bg-primary/60 focus-visible:bg-primary focus-visible:outline-none" - /> +
- {/* Canvas */} -
{ - if (event.dataTransfer.types.includes(TOOL_DRAG_TYPE)) { - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - } - }} - onDrop={handleCanvasDrop} - onClick={(event) => { - if (event.target === event.currentTarget) setSelectedNodeId(null); - }} - >
handleSideResizeStart(event, "inspector")} + onKeyDown={(event) => handleSideResizeKey(event, "inspector")} + className="w-1 shrink-0 cursor-col-resize bg-border/60 hover:bg-primary/60 focus-visible:bg-primary focus-visible:outline-none" + /> + + {/* Inspector */} +
- setGraph((current) => removeEdge(current, edgeId))} - /> - {graph.nodes.map((node) => ( - + portFeedsOutput(graph, selectedNode.id, port.id)) + .map((port) => port.id), + ) + : new Set() + } + onKeepResult={(portId, name) => + selectedNode && handleKeepResult(selectedNode.id, portId, name) + } + onFieldChange={(field, value) => + selectedNode && + setGraph((current) => setNodeField(current, selectedNode.id, field, value)) + } + onParamChange={(paramId, value) => + selectedNode && + setGraph((current) => setNodeParameter(current, selectedNode.id, paramId, value)) + } + onRemove={() => { + if (!selectedNode) return; + setGraph((current) => removeNode(current, selectedNode.id)); + // An armed port on the node being deleted would otherwise stay + // armed and wire the next activation to a node that is gone. + setArmedPort((current) => (current?.nodeId === selectedNode.id ? null : current)); + setSelectedNodeId(null); + }} /> - ))} + + {savedModels.length > 0 && ( +
+ +
+ + +
+
+ )}
- {/* Centred on the visible canvas rather than the scroll extent, which - is wider than the viewport and would push the hint out of sight. */} - {graph.nodes.length === 0 && ( -

- {t("processing.modelBuilder.canvasEmpty")} -

- )}
+ )} + + {/* Issues + log */} + {!minimized && ( + <> +
+
+ + {catalogFailed && ( +
+ {t("processing.modelBuilder.catalogUnavailable")} + +
+ )} + {issues.map((issue, index) => ( +
+ {translateIssue(t, issue)} +
+ ))} + {log.map((line, index) => ( +
+ {line} +
+ ))} + {issues.length === 0 && log.length === 0 && !catalogFailed && ( + + {t("processing.modelBuilder.outputPlaceholder")} + + )} +
+
+ + )} + {/* Resize grip */} + {!minimized && (
handleSideResizeStart(event, "inspector")} - onKeyDown={(event) => handleSideResizeKey(event, "inspector")} - className="w-1 shrink-0 cursor-col-resize bg-border/60 hover:bg-primary/60 focus-visible:bg-primary focus-visible:outline-none" + aria-label={t("processing.modelBuilder.resizePanel")} + className="absolute bottom-0 end-0 h-4 w-4 cursor-nwse-resize focus-visible:bg-primary focus-visible:outline-none" /> - - {/* Inspector */} -
- - portFeedsOutput(graph, selectedNode.id, port.id)) - .map((port) => port.id), - ) - : new Set() - } - onKeepResult={(portId) => selectedNode && handleKeepResult(selectedNode.id, portId)} - onFieldChange={(field, value) => - selectedNode && - setGraph((current) => setNodeField(current, selectedNode.id, field, value)) - } - onParamChange={(paramId, value) => - selectedNode && - setGraph((current) => setNodeParameter(current, selectedNode.id, paramId, value)) - } - onRemove={() => { - if (!selectedNode) return; - setGraph((current) => removeNode(current, selectedNode.id)); - // An armed port on the node being deleted would otherwise stay - // armed and wire the next activation to a node that is gone. - setArmedPort((current) => (current?.nodeId === selectedNode.id ? null : current)); - setSelectedNodeId(null); - }} - /> - - {savedModels.length > 0 && ( -
- -
- - -
-
- )} -
-
- - {/* Issues + log */} -
-
- - {catalogFailed && ( -
- {t("processing.modelBuilder.catalogUnavailable")} - -
- )} - {issues.map((issue, index) => ( -
- {translateIssue(t, issue)} -
- ))} - {log.map((line, index) => ( -
- {line} -
- ))} - {issues.length === 0 && log.length === 0 && !catalogFailed && ( - - {t("processing.modelBuilder.outputPlaceholder")} - - )} -
-
- - {/* Resize grip */} -
+ )} ); } @@ -1715,7 +1752,7 @@ function NodeInspector({ keptPorts: Set; onFieldChange: (field: "layerId" | "name", value: string) => void; onParamChange: (paramId: string, value: unknown) => void; - onKeepResult: (portId: string) => void; + onKeepResult: (portId: string, name: string) => void; onRemove: () => void; }): ReactElement { const { t } = useTranslation(); @@ -1826,7 +1863,18 @@ function NodeInspector({ variant="outline" className="h-6 w-full justify-start px-1.5 text-[11px]" disabled={keptPorts.has(port.id)} - onClick={() => onKeepResult(port.id)} + // Name the result after the tool, so a model that keeps + // several steps does not put a stack of layers all called + // "Model output" on the map. A multi-output tool adds the + // port, since its two results are not the same thing. + onClick={() => + onKeepResult( + port.id, + descriptor.outputs.length === 1 + ? descriptor.name + : `${descriptor.name} (${portLabel(t, port.label)})`, + ) + } > {/* A one-output tool's port name is noise ("Keep \"Output\""), diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 6642281bd8..9ba3fad5a4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -4357,6 +4357,8 @@ "removeConnection": "إزالة الاتصال", "removeNode": "إزالة العقدة", "resizePanel": "تغيير حجم اللوحة", + "minimizePanel": "تصغير اللوحة", + "restorePanel": "استعادة اللوحة", "resizePalette": "تغيير حجم لوحة الأدوات", "resizeInspector": "تغيير حجم لوحة الخصائص", "resizeLog": "تغيير حجم سجل الرسائل", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index afca050df4..82aea2bb90 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -4090,6 +4090,8 @@ "removeConnection": "Verbindung entfernen", "removeNode": "Knoten entfernen", "resizePanel": "Bereichsgröße ändern", + "minimizePanel": "Bereich minimieren", + "restorePanel": "Bereich wiederherstellen", "resizePalette": "Werkzeugpalette in der Größe ändern", "resizeInspector": "Eigenschaftenbereich in der Größe ändern", "resizeLog": "Meldungsprotokoll in der Größe ändern", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 0479fdc38f..7c4d6f2182 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4100,6 +4100,8 @@ "removeConnection": "Remove connection", "removeNode": "Remove node", "resizePanel": "Resize panel", + "minimizePanel": "Minimize panel", + "restorePanel": "Restore panel", "resizePalette": "Resize the tool palette", "resizeInspector": "Resize the properties panel", "resizeLog": "Resize the message log", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 69aa2889ab..b3214b3126 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -4090,6 +4090,8 @@ "removeConnection": "Quitar conexión", "removeNode": "Quitar nodo", "resizePanel": "Cambiar el tamaño del panel", + "minimizePanel": "Minimizar el panel", + "restorePanel": "Restaurar el panel", "resizePalette": "Cambiar el tamaño de la paleta de herramientas", "resizeInspector": "Cambiar el tamaño del panel de propiedades", "resizeLog": "Cambiar el tamaño del registro de mensajes", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 8f66634964..28ec224249 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -4090,6 +4090,8 @@ "removeConnection": "حذف اتصال", "removeNode": "حذف گره", "resizePanel": "تغییر اندازهٔ پنل", + "minimizePanel": "کوچک‌کردن پنل", + "restorePanel": "بازگرداندن پنل", "resizePalette": "تغییر اندازهٔ پالت ابزارها", "resizeInspector": "تغییر اندازهٔ پنل ویژگی‌ها", "resizeLog": "تغییر اندازهٔ گزارش پیام‌ها", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 8cb8a0b555..87a3eed93e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -4090,6 +4090,8 @@ "removeConnection": "Supprimer la connexion", "removeNode": "Supprimer le nœud", "resizePanel": "Redimensionner le panneau", + "minimizePanel": "Réduire le panneau", + "restorePanel": "Restaurer le panneau", "resizePalette": "Redimensionner la palette d'outils", "resizeInspector": "Redimensionner le panneau des propriétés", "resizeLog": "Redimensionner le journal des messages", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 6e2df2270a..b4c5dc8083 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -4090,6 +4090,8 @@ "removeConnection": "कनेक्शन हटाएँ", "removeNode": "नोड हटाएँ", "resizePanel": "पैनल का आकार बदलें", + "minimizePanel": "पैनल छोटा करें", + "restorePanel": "पैनल पुनर्स्थापित करें", "resizePalette": "उपकरण पैलेट का आकार बदलें", "resizeInspector": "गुण पैनल का आकार बदलें", "resizeLog": "संदेश लॉग का आकार बदलें", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 3c0b18b2bc..1edda158e8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -4023,6 +4023,8 @@ "removeConnection": "Hapus koneksi", "removeNode": "Hapus simpul", "resizePanel": "Ubah ukuran panel", + "minimizePanel": "Perkecil panel", + "restorePanel": "Pulihkan panel", "resizePalette": "Ubah ukuran palet alat", "resizeInspector": "Ubah ukuran panel properti", "resizeLog": "Ubah ukuran log pesan", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 8cfe8e7ac7..4877694d49 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -4090,6 +4090,8 @@ "removeConnection": "Rimuovi collegamento", "removeNode": "Rimuovi nodo", "resizePanel": "Ridimensiona il pannello", + "minimizePanel": "Riduci il pannello", + "restorePanel": "Ripristina il pannello", "resizePalette": "Ridimensiona la tavolozza degli strumenti", "resizeInspector": "Ridimensiona il pannello delle proprietà", "resizeLog": "Ridimensiona il registro dei messaggi", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 037f914758..a8fd6521eb 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -4023,6 +4023,8 @@ "removeConnection": "接続を削除", "removeNode": "ノードを削除", "resizePanel": "パネルのサイズを変更", + "minimizePanel": "パネルを最小化", + "restorePanel": "パネルを元に戻す", "resizePalette": "ツールパレットのサイズを変更", "resizeInspector": "プロパティパネルのサイズを変更", "resizeLog": "メッセージログのサイズを変更", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index 62e3280de8..8de41c14b2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -4090,6 +4090,8 @@ "removeConnection": "კავშირის წაშლა", "removeNode": "კვანძის წაშლა", "resizePanel": "პანელის ზომის შეცვლა", + "minimizePanel": "პანელის ჩაკეცვა", + "restorePanel": "პანელის აღდგენა", "resizePalette": "ხელსაწყოთა პალიტრის ზომის შეცვლა", "resizeInspector": "თვისებების პანელის ზომის შეცვლა", "resizeLog": "შეტყობინებების ჟურნალის ზომის შეცვლა", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index e71d083271..7af105351d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -4023,6 +4023,8 @@ "removeConnection": "연결 제거", "removeNode": "노드 제거", "resizePanel": "패널 크기 조정", + "minimizePanel": "패널 최소화", + "restorePanel": "패널 복원", "resizePalette": "도구 팔레트 크기 조정", "resizeInspector": "속성 패널 크기 조정", "resizeLog": "메시지 로그 크기 조정", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 234b0126d4..d8cf7786b6 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -4090,6 +4090,8 @@ "removeConnection": "Verbinding verwijderen", "removeNode": "Knooppunt verwijderen", "resizePanel": "Paneelgrootte wijzigen", + "minimizePanel": "Paneel minimaliseren", + "restorePanel": "Paneel herstellen", "resizePalette": "Grootte van het gereedschapspalet wijzigen", "resizeInspector": "Grootte van het eigenschappenpaneel wijzigen", "resizeLog": "Grootte van het berichtenlogboek wijzigen", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index ae258832a4..b3fa92c910 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -4090,6 +4090,8 @@ "removeConnection": "Remover ligação", "removeNode": "Remover nó", "resizePanel": "Redimensionar painel", + "minimizePanel": "Minimizar painel", + "restorePanel": "Restaurar painel", "resizePalette": "Redimensionar a paleta de ferramentas", "resizeInspector": "Redimensionar o painel de propriedades", "resizeLog": "Redimensionar o registo de mensagens", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index de239898da..47dc24524f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -4224,6 +4224,8 @@ "removeConnection": "Удалить связь", "removeNode": "Удалить узел", "resizePanel": "Изменить размер панели", + "minimizePanel": "Свернуть панель", + "restorePanel": "Развернуть панель", "resizePalette": "Изменить размер палитры инструментов", "resizeInspector": "Изменить размер панели свойств", "resizeLog": "Изменить размер журнала сообщений", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 534d7c0d97..7f8907a9ec 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -4023,6 +4023,8 @@ "removeConnection": "ลบการเชื่อมต่อ", "removeNode": "ลบโหนด", "resizePanel": "ปรับขนาดแผง", + "minimizePanel": "ย่อแผง", + "restorePanel": "คืนขนาดแผง", "resizePalette": "ปรับขนาดแผงเครื่องมือ", "resizeInspector": "ปรับขนาดแผงคุณสมบัติ", "resizeLog": "ปรับขนาดบันทึกข้อความ", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 607be37862..79f8ee0fdb 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -4090,6 +4090,8 @@ "removeConnection": "Bağlantıyı kaldır", "removeNode": "Düğümü kaldır", "resizePanel": "Paneli yeniden boyutlandır", + "minimizePanel": "Paneli küçült", + "restorePanel": "Paneli geri yükle", "resizePalette": "Araç paletini yeniden boyutlandır", "resizeInspector": "Özellikler panelini yeniden boyutlandır", "resizeLog": "İleti günlüğünü yeniden boyutlandır", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index 8b13a952ee..cc606f9547 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -4109,6 +4109,8 @@ "removeConnection": "Xóa kết nối", "removeNode": "Xóa nút", "resizePanel": "Đổi kích thước bảng", + "minimizePanel": "Thu nhỏ bảng", + "restorePanel": "Khôi phục bảng", "resizePalette": "Đổi kích thước bảng công cụ", "resizeInspector": "Đổi kích thước bảng thuộc tính", "resizeLog": "Đổi kích thước nhật ký thông báo", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index bf686c2b50..42cd776c22 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -4023,6 +4023,8 @@ "removeConnection": "删除连接", "removeNode": "删除节点", "resizePanel": "调整面板大小", + "minimizePanel": "最小化面板", + "restorePanel": "还原面板", "resizePalette": "调整工具面板大小", "resizeInspector": "调整属性面板大小", "resizeLog": "调整消息日志大小", diff --git a/apps/geolibre-desktop/src/lib/model-graph-edit.ts b/apps/geolibre-desktop/src/lib/model-graph-edit.ts index 6bef353efb..32b6f6fcea 100644 --- a/apps/geolibre-desktop/src/lib/model-graph-edit.ts +++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts @@ -461,6 +461,10 @@ export function graphsEqual(a: ProcessingModelGraph, b: ProcessingModelGraph): b * @param nodeId The tool whose result should be kept. * @param portId The output port to tap. * @param createId Fresh id source. + * @param name Suggested result name, normally the tool's display name. Made + * unique against the outputs already in the graph, since an unnamed output + * falls back to a single shared "Model output" label and a model that keeps + * several steps would put indistinguishable layers on the map. * @returns The updated graph and the new node's id, or `null` when the tool is * not in the graph. */ @@ -469,6 +473,7 @@ export function addOutputForPort( nodeId: string, portId: string, createId: () => string, + name?: string, ): { graph: ProcessingModelGraph; nodeId: string } | null { const source = graph.nodes.find((node) => node.id === nodeId); if (!source) return null; @@ -480,8 +485,17 @@ export function addOutputForPort( { x: source.x + NODE_WIDTH + 72, y: source.y }, createId, ); + const resultName = name?.trim() ? uniqueOutputName(graph, name.trim()) : ""; + const named: ProcessingModelGraph = resultName + ? { + ...added.graph, + nodes: added.graph.nodes.map((node) => + node.id === added.nodeId ? { ...node, name: resultName } : node, + ), + } + : added.graph; const connected = connectNodes( - added.graph, + named, { nodeId, portId }, { nodeId: added.nodeId, portId: OUTPUT_NODE_PORT }, createId, @@ -489,10 +503,33 @@ export function addOutputForPort( // A brand-new output node cannot close a loop or target itself, so a // rejection here is not reachable; fall back to the unwired node rather than // dropping the user's click on the floor. - if ("rejected" in connected) return { graph: added.graph, nodeId: added.nodeId }; + if ("rejected" in connected) return { graph: named, nodeId: added.nodeId }; return { graph: connected.graph, nodeId: added.nodeId }; } +/** + * A result name not already taken by another `output` node, by appending a + * counter. Two Buffer steps both named "Buffer" would otherwise land on the + * map as two layers the user cannot tell apart. + * + * @param graph The current graph. + * @param base The preferred name. + * @returns `base`, or `base` with the lowest free counter appended. + */ +export function uniqueOutputName(graph: ProcessingModelGraph, base: string): string { + const taken = new Set( + graph.nodes + .filter((node) => node.kind === "output") + .map((node) => node.name?.trim()) + .filter((name): name is string => Boolean(name)), + ); + if (!taken.has(base)) return base; + for (let n = 2; ; n++) { + const candidate = `${base} ${n}`; + if (!taken.has(candidate)) return candidate; + } +} + /** True when this output port already feeds an `output` node. */ export function portFeedsOutput( graph: ProcessingModelGraph, diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts index 664cbedb24..84eeb25390 100644 --- a/tests/model-graph-edit.test.ts +++ b/tests/model-graph-edit.test.ts @@ -19,6 +19,7 @@ import { setNodeField, setNodeParameter, settleNode, + uniqueOutputName, NODE_HEIGHT, NODE_WIDTH, } from "../apps/geolibre-desktop/src/lib/model-graph-edit"; @@ -562,4 +563,54 @@ describe("keeping an intermediate result", () => { it("does not count a port that only feeds another tool", () => { assert.equal(portFeedsOutput(chain(), "in", "out"), false); }); + + it("names the kept output after the tool", () => { + // Otherwise every kept step falls back to one shared "Model output" label + // and the map ends up with layers the user cannot tell apart. + const result = addOutputForPort(chain(), "t1", "out", ids, "Buffer"); + assert.ok(result); + assert.equal(result.graph.nodes.find((node) => node.id === result.nodeId)?.name, "Buffer"); + }); + + it("counts up rather than reusing a name another output already has", () => { + const first = addOutputForPort(chain(), "t1", "out", ids, "Buffer"); + assert.ok(first); + const second = addOutputForPort(first.graph, "t2", "out", ids, "Buffer"); + assert.ok(second); + assert.equal(second.graph.nodes.find((node) => node.id === second.nodeId)?.name, "Buffer 2"); + }); + + it("leaves the name empty when none is suggested", () => { + const result = addOutputForPort(chain(), "t1", "out", ids); + assert.ok(result); + assert.equal(result.graph.nodes.find((node) => node.id === result.nodeId)?.name, ""); + }); +}); + +describe("uniqueOutputName", () => { + const withOutputs = (...names: string[]): ProcessingModelGraph => ({ + nodes: names.map((name, i) => ({ id: `o${i}`, kind: "output" as const, x: 0, y: 0, name })), + edges: [], + }); + + it("returns the base name when it is free", () => { + assert.equal(uniqueOutputName(withOutputs("Centroids"), "Buffer"), "Buffer"); + }); + + it("appends the lowest free counter", () => { + assert.equal(uniqueOutputName(withOutputs("Buffer"), "Buffer"), "Buffer 2"); + assert.equal(uniqueOutputName(withOutputs("Buffer", "Buffer 2"), "Buffer"), "Buffer 3"); + }); + + it("skips over a gap rather than reusing a taken name", () => { + assert.equal(uniqueOutputName(withOutputs("Buffer", "Buffer 3"), "Buffer"), "Buffer 2"); + }); + + it("ignores names on nodes that are not outputs", () => { + const graph: ProcessingModelGraph = { + nodes: [{ id: "t", kind: "tool", x: 0, y: 0, provider: "vector", toolId: "buffer" }], + edges: [], + }; + assert.equal(uniqueOutputName(graph, "Buffer"), "Buffer"); + }); }); From b71ca2e59e0e3db772305174876ab5841b7c18f9 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 19 Aug 2026 22:59:36 -0400 Subject: [PATCH 21/22] Fit the Model Builder to small screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel could not shrink below its 820x420 preferred minimum, and it only sized itself once, when first opened. Measured against the real map area, it overflowed to the right by 191px at 1024x700, 348px at 800x600 and 400px at 420x780 — so on a small window the inspector, Run and Close all sat off the edge, unreachable. Two causes, both fixed: - MIN_WIDTH/MIN_HEIGHT were hard floors. They are now *preferences*: the panel clamps to the smaller of the preferred minimum and what the container actually offers, down to an absolute FLOOR_WIDTH/FLOOR_HEIGHT. The pointer and keyboard resize paths use the same adaptive floors. - The fit ran only on open. A ResizeObserver on the map area now re-fits whenever it changes, so a resized window or a newly opened side panel no longer leaves the panel sized for a viewport that is gone. The observed element is the map area, which the panel does not affect, so this cannot feed back on itself. Below COMPACT_WIDTH the three columns cannot share a row without leaving the canvas a sliver, so the layout adapts rather than just shrinking: the palette and inspector become overlays opened one at a time from the toolbar, the canvas keeps the full width, the column splitters are dropped (nothing to drag), and the toolbar drops its button labels for icons. Every button keeps an aria-label and title, so icon-only stays readable to screen readers and on hover. Verified in a browser at 1024x700, 800x600, 640x800 and 420x780: the panel now sits inside the map area at every size with its 12px margin intact, and the canvas gets essentially the full panel width. At 480x820 all ten toolbar buttons are within the panel box, Run and Close are visible, both overlays open and close, and a node added from the overlay palette opens in the overlay inspector. Growing the window back to 1400x900 restores the three-column layout with both splitters live. --- .../model-builder/ModelBuilderPanel.tsx | 576 +++++++++++------- 1 file changed, 361 insertions(+), 215 deletions(-) diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 259ad28b1d..07244c32c1 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -34,6 +34,8 @@ import { Download, GripVertical, LayoutGrid, + PanelLeft, + PanelRight, Loader2, Play, Plus, @@ -125,8 +127,25 @@ const MAX_IMPORT_EDGES = 4000; */ const MAX_IMPORT_BYTES = 16 * 1024 * 1024; +/** + * Preferred floors. They are *preferences*, not hard limits: a container + * narrower than this gets a panel narrower than this, because a panel that + * refuses to shrink below 820px on a 420px screen simply hangs off the edge + * with its Run and Close buttons unreachable. + */ const MIN_WIDTH = 820; const MIN_HEIGHT = 420; +/** Absolute floors, below which the panel stops being usable at all. */ +const FLOOR_WIDTH = 260; +const FLOOR_HEIGHT = 220; +/** Width a side pane takes when it overlays the canvas in compact mode. */ +const COMPACT_PANE_WIDTH = 240; +/** + * Below this the palette, canvas and inspector cannot share a row: three + * columns leave the canvas a sliver. The side panes become overlays opened one + * at a time from the title bar instead, so the canvas keeps the full width. + */ +const COMPACT_WIDTH = 720; const EDGE_MARGIN = 12; /** A best-effort unique id (the webview always has crypto.randomUUID). */ @@ -183,6 +202,12 @@ export function ModelBuilderPanel({ * while collapsed — the point is to watch its results land. */ const [minimized, setMinimized] = useState(false); + /** + * Which side pane is showing in compact mode, where they overlay the canvas + * rather than sit beside it. Null means neither, which is the useful default + * on a small screen: the canvas is what there is least room for. + */ + const [compactPane, setCompactPane] = useState<"palette" | "inspector" | null>(null); const [modelId, setModelId] = useState(() => createId()); const [modelName, setModelName] = useState(""); const [graph, setGraph] = useState(emptyModelGraph); @@ -209,26 +234,42 @@ export function ModelBuilderPanel({ const appendLog = useCallback((line: string) => setLog((prev) => [...prev, line]), []); - // The default size assumes a full-width map; open side panels can leave much - // less, which would push the inspector column out of view. Shrink to whatever - // the map area actually offers the first time the panel is shown. - useLayoutEffect(() => { - if (!open) return; + /** + * Keep the panel inside the map area. + * + * The default size assumes a full-width map; open side panels, a small + * window or a phone leave much less. The floors here are the *smaller* of + * the preferred minimum and what the container actually offers, so the panel + * shrinks to fit instead of hanging off the edge with its buttons out of + * reach. + */ + const fitToContainer = useCallback(() => { const bounds = sectionRef.current?.parentElement?.getBoundingClientRect(); if (!bounds) return; - setSize((current) => ({ - width: clamp(current.width, MIN_WIDTH, Math.max(MIN_WIDTH, bounds.width - EDGE_MARGIN * 2)), - height: clamp( - current.height, - MIN_HEIGHT, - Math.max(MIN_HEIGHT, bounds.height - EDGE_MARGIN * 2), - ), - })); + const maxWidth = Math.max(FLOOR_WIDTH, bounds.width - EDGE_MARGIN * 2); + const maxHeight = Math.max(FLOOR_HEIGHT, bounds.height - EDGE_MARGIN * 2); + const width = clamp(size.width, Math.min(MIN_WIDTH, maxWidth), maxWidth); + const height = clamp(size.height, Math.min(MIN_HEIGHT, maxHeight), maxHeight); + if (width !== size.width || height !== size.height) setSize({ width, height }); setPosition((current) => ({ - x: clamp(current.x, 0, Math.max(0, bounds.width - MIN_WIDTH - EDGE_MARGIN)), - y: clamp(current.y, 0, Math.max(0, bounds.height - MIN_HEIGHT - EDGE_MARGIN)), + x: clamp(current.x, 0, Math.max(0, bounds.width - width - EDGE_MARGIN)), + y: clamp(current.y, 0, Math.max(0, bounds.height - height - EDGE_MARGIN)), })); - }, [open]); + }, [size.width, size.height]); + + // Re-fit whenever the map area changes, not just when the panel opens: a + // resized window or a side panel opening would otherwise leave the panel + // sized for a viewport that is gone. The observed element is the map area, + // which the panel does not affect, so this cannot feed back on itself. + useLayoutEffect(() => { + if (!open) return; + fitToContainer(); + const host = sectionRef.current?.parentElement; + if (!host || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => fitToContainer()); + observer.observe(host); + return () => observer.disconnect(); + }, [open, fitToContainer]); // Load both registries once the panel is first opened. The Whitebox catalog is // a fetched snapshot and the WASM manifests load the binary, so they run @@ -860,6 +901,23 @@ export function ModelBuilderPanel({ * is the opposite screen direction — hence the sign taken from the element's * computed `direction` rather than assuming left-to-right. */ + /** + * Too narrow for three columns. The side panes overlay the canvas instead of + * splitting the row with it, and the toolbar drops its button labels. + */ + const compact = size.width < COMPACT_WIDTH; + + /** Resize floors, matching what {@link fitToContainer} allows. */ + const hostBounds = sectionRef.current?.parentElement?.getBoundingClientRect(); + const minWidth = Math.min( + MIN_WIDTH, + Math.max(FLOOR_WIDTH, (hostBounds?.width ?? MIN_WIDTH) - EDGE_MARGIN * 2), + ); + const minHeight = Math.min( + MIN_HEIGHT, + Math.max(FLOOR_HEIGHT, (hostBounds?.height ?? MIN_HEIGHT) - EDGE_MARGIN * 2), + ); + /** Upper bound for one side column at the panel's current width. */ const maxSideWidth = Math.max( MIN_SIDE_WIDTH, @@ -977,11 +1035,11 @@ export function ModelBuilderPanel({ const maxWidth = bounds ? bounds.width - position.x - EDGE_MARGIN : Infinity; const maxHeight = bounds ? bounds.height - position.y - EDGE_MARGIN : Infinity; setSize((current) => ({ - width: clamp(current.width + dx * dirSign, MIN_WIDTH, Math.max(MIN_WIDTH, maxWidth)), - height: clamp(current.height + dy, MIN_HEIGHT, Math.max(MIN_HEIGHT, maxHeight)), + width: clamp(current.width + dx * dirSign, minWidth, Math.max(minWidth, maxWidth)), + height: clamp(current.height + dy, minHeight, Math.max(minHeight, maxHeight)), })); }, - [position.x, position.y], + [position.x, position.y, minWidth, minHeight], ); const handleResizeStart = (event: ReactPointerEvent) => { @@ -999,15 +1057,11 @@ export function ModelBuilderPanel({ const maxWidth = bounds ? bounds.width - position.x - EDGE_MARGIN : Infinity; const maxHeight = bounds ? bounds.height - position.y - EDGE_MARGIN : Infinity; setSize({ - width: clamp( - start.width + (move.clientX - startX), - MIN_WIDTH, - Math.max(MIN_WIDTH, maxWidth), - ), + width: clamp(start.width + (move.clientX - startX), minWidth, Math.max(minWidth, maxWidth)), height: clamp( start.height + (move.clientY - startY), - MIN_HEIGHT, - Math.max(MIN_HEIGHT, maxHeight), + minHeight, + Math.max(minHeight, maxHeight), ), }); }; @@ -1050,64 +1104,126 @@ export function ModelBuilderPanel({ > {/* Title bar doubles as the drag handle. */}