+ {/* Palette. Compact: an overlay over the canvas, opened from the
+ toolbar, so three columns never have to share a narrow row. */}
+ {(!compact || compactPane === "palette") && (
+
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")}
+
+ )}
+
+
+ {!compact && (
+
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 */}
+ {(!compact || compactPane === "inspector") && (
+
+
+ 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 && (
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+ );
+}
+
+/**
+ * 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,
+ 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;
+ // Same geometry the card uses, or a labelled multi-port node would draw its
+ // curves to where the dots used to be.
+ const layout = cardLayout(ports);
+ return portPosition(
+ node,
+ index,
+ list.length,
+ side,
+ layout.height,
+ side === "in" ? layout.labelIn : layout.labelOut,
+ );
+ };
+
+ 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 }[] } {
+ // 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 ?? [] };
+}
+
+/**
+ * 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;
+ layers: GeoLibreLayer[];
+ 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);
+ const layout = cardLayout(ports);
+ 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 (
+ // 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: layout.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",
+ layout.height,
+ layout.labelIn,
+ );
+ return (
+
+
+ );
+ })}
+ {ports.outputs.map((port, index) => {
+ const at = portPosition(
+ node,
+ index,
+ ports.outputs.length,
+ "out",
+ layout.height,
+ layout.labelOut,
+ );
+ return (
+
+
+ );
+ })}
+
+ );
+});
+
+/** Right-hand properties panel for whichever node is selected. */
+function NodeInspector({
+ node,
+ 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, name: string) => 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) => (
+
+ {translateIssue(t, issue)}
+
+ ))}
+
+ {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)}
+ />
+ ))
+ )}
+ {/* 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) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+/** 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.
+ *
+ * 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;
+}
+
+/**
+ * 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.
+ *
+ * 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,
+ descriptor,
+ inputs,
+ signal,
+ layers,
+ duckdb,
+ log,
+ t,
+}: {
+ node: ModelGraphNode;
+ descriptor: ModelToolDescriptor;
+ inputs: Record;
+ signal?: AbortSignal;
+ 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(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(
+ t("processing.modelBuilder.portNeedsVector", { port: portLabel(t, portId) }),
+ );
+ }
+ 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(t("processing.modelBuilder.toolNoOutput", { tool: descriptor.name }));
+ 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 ?? {}) },
+ // 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",
+ });
+ 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(t("processing.modelBuilder.toolNoUsableOutput", { tool: descriptor.name }));
+ }
+ 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 ab3ff57e43..9ba3fad5a4 100644
--- a/apps/geolibre-desktop/src/i18n/locales/ar.json
+++ b/apps/geolibre-desktop/src/i18n/locales/ar.json
@@ -2395,7 +2395,8 @@
"dashboard": "لوحة المعلومات",
"assistant": "مساعد الذكاء الاصطناعي",
"geocode": "الترميز الجغرافي للعناوين",
- "modelBuilder": "المعالجة الدفعية والنماذج",
+ "batchTools": "أدوات الدفعات",
+ "modelBuilder": "منشئ النماذج",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "التجزئة بالذكاء الاصطناعي",
@@ -2723,7 +2724,7 @@
"duckdbLayer": "طبقة DuckDB",
"whitebox": "Whitebox",
"geocode": "الترميز الجغرافي للعناوين",
- "modelBuilder": "الدفعات والنماذج",
+ "modelBuilder": "منشئ النماذج",
"processingHistory": "السجل",
"conversion": "التحويل",
"vector": "المتجهات",
@@ -2902,7 +2903,8 @@
"projectName": "اسم المشروع",
"storymapEllipsis": "خريطة قصصية...",
"pointerElevationNoticeTitle": "الارتفاع يستخدم خدمة ارتفاعات عامة",
- "pointerElevationNoticeDesc": "يُحسب الارتفاع من تضاريس الخريطة ثلاثية الأبعاد عند تفعيلها دون إرسال أي بيانات. وبدونها تُستخدم واجهة Open-Meteo العامة، فتغادر إحداثيات المؤشر جهازك."
+ "pointerElevationNoticeDesc": "يُحسب الارتفاع من تضاريس الخريطة ثلاثية الأبعاد عند تفعيلها دون إرسال أي بيانات. وبدونها تُستخدم واجهة Open-Meteo العامة، فتغادر إحداثيات المؤشر جهازك.",
+ "batchTools": "أدوات الدفعات"
},
"plugin": {
"maplibre-gl-annotations": "التعليقات التوضيحية",
@@ -4296,33 +4298,97 @@
"count_other": "تم تسجيل {{count}} عملية تشغيل",
"toolUnavailable": "الأداة «{{toolId}}» لم تعد متوفرة"
},
- "modelBuilder": {
- "moveStepUp": "نقل الخطوة لأعلى",
- "moveStepDown": "نقل الخطوة لأسفل",
- "removeStep": "إزالة الخطوة",
- "title": "الدُفعات والنماذج",
- "description": "شغّل أداة متجهات على عدة طبقات، أو اربط الأدوات معًا في نموذج قابل لإعادة الاستخدام يُحفَظ مع مشروعك.",
- "tabBatch": "دفعة",
- "tabModels": "النماذج",
- "outputPlaceholder": "سيظهر الناتج هنا.",
+ "batchTools": {
+ "title": "أدوات الدفعات",
+ "description": "تشغيل أداة متجهية واحدة على عدة طبقات دفعة واحدة.",
+ "runBatch": "تشغيل الدفعة",
"tool": "الأداة",
"sharedParameters": "المعاملات المشتركة",
"noExtraParameters": "لا تحتوي هذه الأداة على معاملات إضافية.",
"inputLayers": "طبقات الإدخال",
- "selectAll": "تحديد الكل",
"clearSelection": "مسح",
+ "selectAll": "تحديد الكل",
"noCompatibleLayers": "لا توجد طبقات GeoJSON متوافقة.",
- "newModel": "نموذج جديد",
- "noSavedModels": "لا توجد نماذج محفوظة بعد.",
- "untitledModel": "نموذج بلا عنوان",
+ "outputPlaceholder": "سيظهر الناتج هنا."
+ },
+ "modelBuilder": {
+ "title": "منشئ النماذج",
+ "description": "اسحب الأدوات إلى لوحة الرسم واربطها معًا في نموذج معالجة.",
"modelName": "اسم النموذج",
- "emptyPipelineHint": "أضف خطوة لبدء بناء سلسلة المعالجة. تقرأ الخطوة الأولى طبقة إدخال، وتتلقى كل خطوة تالية ناتج الخطوة السابقة.",
- "addStep": "إضافة خطوة",
- "runModel": "تشغيل النموذج",
+ "modelNamePlaceholder": "نموذج بلا عنوان",
+ "untitledModel": "نموذج بلا عنوان",
+ "newModel": "جديد",
+ "discardChanges": "هل تريد تجاهل التغييرات غير المحفوظة في النموذج الحالي؟",
+ "arrange": "ترتيب",
+ "arrangeHint": "ترتيب العقد على امتداد مسار التدفق",
+ "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": "النماذج المحفوظة",
+ "loadModelPlaceholder": "تحميل نموذج محفوظ...",
"deleteModel": "حذف",
- "inputPreviousStep": "الإدخال: → ناتج الخطوة السابقة",
- "unknownTool": "أداة غير معروفة \"{{id}}\"",
- "noParameters": "لا توجد معاملات."
+ "deletedLog": "تم حذف النموذج من المشروع.",
+ "searchTools": "البحث عن الأدوات",
+ "loadingTools": "جارٍ تحميل الأدوات...",
+ "noToolsMatch": "لا توجد أدوات تطابق بحثك.",
+ "addInputNode": "+ مدخل",
+ "addOutputNode": "+ مخرج",
+ "canvasEmpty": "اسحب أداة من لوحة الأدوات لبدء البناء.",
+ "inputNode": "مدخل",
+ "outputNode": "مخرج",
+ "inputPort": "مدخل: {{port}}",
+ "outputPort": "مخرج: {{port}}",
+ "removeConnection": "إزالة الاتصال",
+ "removeNode": "إزالة العقدة",
+ "resizePanel": "تغيير حجم اللوحة",
+ "minimizePanel": "تصغير اللوحة",
+ "restorePanel": "استعادة اللوحة",
+ "resizePalette": "تغيير حجم لوحة الأدوات",
+ "resizeInspector": "تغيير حجم لوحة الخصائص",
+ "resizeLog": "تغيير حجم سجل الرسائل",
+ "selectNodeHint": "اختر عقدة لتحرير إعداداتها.",
+ "sourceLayer": "طبقة المصدر",
+ "chooseLayer": "اختر طبقة...",
+ "resultName": "اسم النتيجة",
+ "resultNamePlaceholder": "مخرج النموذج",
+ "noParameters": "لا توجد معاملات.",
+ "keepResultHint": "احتفظ بنتيجة وسيطة بإضافة مخرج لها.",
+ "keepResultSingle": "الاحتفاظ بهذه النتيجة",
+ "resultKeptSingle": "هذه النتيجة محفوظة",
+ "keepResult": "الاحتفاظ بـ«{{port}}»",
+ "resultKept": "«{{port}}» محفوظ",
+ "outputPlaceholder": "تظهر الرسائل هنا.",
+ "connectCycle": "هذا الاتصال سينشئ حلقة مغلقة.",
+ "connectSameNode": "لا يمكن ربط العقدة بنفسها.",
+ "fixIssuesFirst": "أصلح المشكلات المذكورة قبل التشغيل.",
+ "runFailed": "فشل التشغيل",
+ "runFinished": "انتهى التشغيل — تمت إضافة {{outputs}} مخرج.",
+ "savedLog": "تم حفظ النموذج في المشروع.",
+ "exportedLog": "تم تصدير {{name}}",
+ "importedLog": "تم استيراد نموذج يحتوي على {{nodes}} عقدة.",
+ "importFailed": "فشل الاستيراد",
+ "importInvalid": "هذا الملف لا يحتوي على مخطط نموذج.",
+ "importUnsupported": "هذا الملف ليس نموذج GeoLibre.",
+ "rasterOutputUnsupported": "«{{name}}» نتيجة راستر لا يمكن لهذه النسخة إضافتها إلى الخريطة.",
+ "portNeedsVector": "«{{port}}» يحتاج إلى بيانات متجهة، لكن وصلت بيانات راستر.",
+ "toolNoOutput": "«{{tool}}» لم تُنتج أي مخرجات.",
+ "toolNoUsableOutput": "«{{tool}}» لم تُنتج مخرجات قابلة للاستخدام."
},
"parameterField": {
"selectLayer": "حدد طبقة...",
@@ -5804,7 +5870,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 52c4411df1..82aea2bb90 100644
--- a/apps/geolibre-desktop/src/i18n/locales/de.json
+++ b/apps/geolibre-desktop/src/i18n/locales/de.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "DuckDB-Ebene",
"whitebox": "Whitebox",
"geocode": "Adressen geokodieren",
- "modelBuilder": "Stapel & Modelle",
+ "modelBuilder": "Modellbaukasten",
"processingHistory": "Verlauf",
"conversion": "Konvertierung",
"vector": "Vektor",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Stapel ausführen",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Gespeichertes Modell laden ...",
"deleteModel": "Löschen",
- "inputPreviousStep": "Eingabe: ← Ausgabe des vorherigen Schritts",
- "unknownTool": "Unbekanntes Werkzeug „{{id}}“",
- "noParameters": "Keine Parameter."
+ "deletedLog": "Modell aus dem Projekt gelöscht.",
+ "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",
+ "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",
+ "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.",
+ "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.",
+ "importUnsupported": "Diese Datei ist kein GeoLibre-Modell.",
+ "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 …",
@@ -5485,7 +5551,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 b4df42cfbd..7c4d6f2182 100644
--- a/apps/geolibre-desktop/src/i18n/locales/en.json
+++ b/apps/geolibre-desktop/src/i18n/locales/en.json
@@ -2226,7 +2226,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",
@@ -2554,7 +2555,7 @@
"duckdbLayer": "DuckDB Layer",
"whitebox": "Whitebox",
"geocode": "Geocode Addresses",
- "modelBuilder": "Batch & Models",
+ "modelBuilder": "Model Builder",
"processingHistory": "History",
"conversion": "Conversion",
"vector": "Vector",
@@ -2721,7 +2722,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",
@@ -4039,33 +4041,97 @@
"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.",
+ "runBatch": "Run batch",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Load a saved model...",
"deleteModel": "Delete",
- "inputPreviousStep": "Input: ← previous step output",
- "unknownTool": "Unknown tool \"{{id}}\"",
- "noParameters": "No parameters."
+ "deletedLog": "Model deleted from the project.",
+ "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",
+ "minimizePanel": "Minimize panel",
+ "restorePanel": "Restore 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.",
+ "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.",
+ "importUnsupported": "That file is not a GeoLibre model.",
+ "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...",
@@ -5480,6 +5546,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 335087a739..b3214b3126 100644
--- a/apps/geolibre-desktop/src/i18n/locales/es.json
+++ b/apps/geolibre-desktop/src/i18n/locales/es.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "Capa DuckDB",
"whitebox": "Whitebox",
"geocode": "Geocodificar direcciones",
- "modelBuilder": "Lotes y modelos",
+ "modelBuilder": "Constructor de modelos",
"processingHistory": "Historial",
"conversion": "Conversión",
"vector": "Vectorial",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Ejecutar lote",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Cargar un modelo guardado...",
"deleteModel": "Eliminar",
- "inputPreviousStep": "Entrada: ← salida del paso anterior",
- "unknownTool": "Herramienta desconocida «{{id}}»",
- "noParameters": "Sin parámetros."
+ "deletedLog": "Modelo eliminado del proyecto.",
+ "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",
+ "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",
+ "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.",
+ "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.",
+ "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.",
+ "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...",
@@ -5485,7 +5551,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 dc52f0b654..28ec224249 100644
--- a/apps/geolibre-desktop/src/i18n/locales/fa.json
+++ b/apps/geolibre-desktop/src/i18n/locales/fa.json
@@ -2216,7 +2216,8 @@
"dashboard": "داشبورد",
"assistant": "دستیار هوش مصنوعی",
"geocode": "مکانیابی نشانیها",
- "modelBuilder": "دستهای و مدلها",
+ "batchTools": "ابزارهای دستهای",
+ "modelBuilder": "سازندهٔ مدل",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "قطعهبندی هوش مصنوعی",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "لایهٔ DuckDB",
"whitebox": "Whitebox",
"geocode": "مکانیابی نشانیها",
- "modelBuilder": "دستهای و مدلها",
+ "modelBuilder": "سازندهٔ مدل",
"processingHistory": "تاریخچه",
"conversion": "تبدیل",
"vector": "برداری",
@@ -2711,7 +2712,8 @@
"projectName": "نام پروژه",
"storymapEllipsis": "نقشهٔ روایی...",
"pointerElevationNoticeTitle": "ارتفاع از یک سرویس عمومی گرفته میشود",
- "pointerElevationNoticeDesc": "وقتی زمین سهبعدی فعال باشد ارتفاع از خود نقشه محاسبه میشود و چیزی ارسال نمیگردد. در نبود زمین سهبعدی از Open-Meteo عمومی استفاده میشود و مختصات زیر نشانگر از دستگاه شما خارج میشود."
+ "pointerElevationNoticeDesc": "وقتی زمین سهبعدی فعال باشد ارتفاع از خود نقشه محاسبه میشود و چیزی ارسال نمیگردد. در نبود زمین سهبعدی از Open-Meteo عمومی استفاده میشود و مختصات زیر نشانگر از دستگاه شما خارج میشود.",
+ "batchTools": "ابزارهای دستهای"
},
"plugin": {
"maplibre-gl-annotations": "حاشیهنویسیها",
@@ -4029,33 +4031,97 @@
"count_other": "{{count}} اجرا ثبت شد",
"toolUnavailable": "ابزار «{{toolId}}» دیگر در دسترس نیست"
},
- "modelBuilder": {
- "moveStepUp": "بردن گام به بالا",
- "moveStepDown": "بردن گام به پایین",
- "removeStep": "حذف گام",
- "title": "دستهای و مدلها",
- "description": "یک ابزار برداری را روی چند لایه اجرا کنید، یا ابزارها را به هم زنجیر کنید و بهصورت مدلی دوبارهاستفادهشدنی همراه پروژهٔ خود ذخیره کنید.",
- "tabBatch": "دستهای",
- "tabModels": "مدلها",
- "outputPlaceholder": "خروجی اینجا نمایان میشود.",
+ "batchTools": {
+ "title": "ابزارهای دستهای",
+ "description": "اجرای یک ابزار برداری روی چند لایه بهصورت یکجا.",
+ "runBatch": "اجرای دسته",
"tool": "ابزار",
"sharedParameters": "پارامترهای مشترک",
"noExtraParameters": "این ابزار پارامتر افزودهای ندارد.",
"inputLayers": "لایههای ورودی",
- "selectAll": "انتخاب همه",
"clearSelection": "پاک کردن",
+ "selectAll": "انتخاب همه",
"noCompatibleLayers": "هیچ لایهٔ GeoJSON سازگاری نیست.",
- "newModel": "مدل جدید",
- "noSavedModels": "هنوز مدل ذخیرهشدهای نیست.",
- "untitledModel": "مدل بینام",
+ "outputPlaceholder": "خروجی اینجا نمایان میشود."
+ },
+ "modelBuilder": {
+ "title": "سازندهٔ مدل",
+ "description": "ابزارها را روی بوم بکشید و آنها را به هم وصل کنید تا یک مدل پردازشی بسازید.",
"modelName": "نام مدل",
- "emptyPipelineHint": "برای آغاز ساخت خط لوله، یک گام بیفزایید. گام نخست یک لایهٔ ورودی میخواند؛ هر گام بعدی خروجی گام پیشین را میگیرد.",
- "addStep": "افزودن گام",
- "runModel": "اجرای مدل",
+ "modelNamePlaceholder": "مدل بدون عنوان",
+ "untitledModel": "مدل بدون عنوان",
+ "newModel": "جدید",
+ "discardChanges": "تغییرات ذخیرهنشدهٔ مدل کنونی دور انداخته شوند؟",
+ "arrange": "چیدمان",
+ "arrangeHint": "چیدن گرهها در امتداد جریان",
+ "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": "مدلهای ذخیرهشده",
+ "loadModelPlaceholder": "بارگذاری یک مدل ذخیرهشده...",
"deleteModel": "حذف",
- "inputPreviousStep": "ورودی: → خروجی گام پیشین",
- "unknownTool": "ابزار ناشناختهٔ «{{id}}»",
- "noParameters": "بدون پارامتر."
+ "deletedLog": "مدل از پروژه حذف شد.",
+ "searchTools": "جستوجوی ابزارها",
+ "loadingTools": "در حال بارگذاری ابزارها...",
+ "noToolsMatch": "هیچ ابزاری با جستوجوی شما مطابقت ندارد.",
+ "addInputNode": "+ ورودی",
+ "addOutputNode": "+ خروجی",
+ "canvasEmpty": "برای شروع، یک ابزار را از پالت بکشید.",
+ "inputNode": "ورودی",
+ "outputNode": "خروجی",
+ "inputPort": "ورودی: {{port}}",
+ "outputPort": "خروجی: {{port}}",
+ "removeConnection": "حذف اتصال",
+ "removeNode": "حذف گره",
+ "resizePanel": "تغییر اندازهٔ پنل",
+ "minimizePanel": "کوچککردن پنل",
+ "restorePanel": "بازگرداندن پنل",
+ "resizePalette": "تغییر اندازهٔ پالت ابزارها",
+ "resizeInspector": "تغییر اندازهٔ پنل ویژگیها",
+ "resizeLog": "تغییر اندازهٔ گزارش پیامها",
+ "selectNodeHint": "برای ویرایش تنظیمات، یک گره را انتخاب کنید.",
+ "sourceLayer": "لایهٔ مبدأ",
+ "chooseLayer": "یک لایه انتخاب کنید...",
+ "resultName": "نام نتیجه",
+ "resultNamePlaceholder": "خروجی مدل",
+ "noParameters": "پارامتری وجود ندارد.",
+ "keepResultHint": "با افزودن یک خروجی، نتیجهٔ میانی را نگه دارید.",
+ "keepResultSingle": "نگهداشتن این نتیجه",
+ "resultKeptSingle": "این نتیجه نگه داشته میشود",
+ "keepResult": "نگهداشتن «{{port}}»",
+ "resultKept": "«{{port}}» نگه داشته میشود",
+ "outputPlaceholder": "پیامها اینجا نمایش داده میشوند.",
+ "connectCycle": "این اتصال یک حلقه ایجاد میکند.",
+ "connectSameNode": "یک گره نمیتواند به خودش وصل شود.",
+ "fixIssuesFirst": "پیش از اجرا، مشکلات گزارششده را برطرف کنید.",
+ "runFailed": "اجرا ناموفق بود",
+ "runFinished": "اجرا به پایان رسید — {{outputs}} خروجی افزوده شد.",
+ "savedLog": "مدل در پروژه ذخیره شد.",
+ "exportedLog": "{{name}} برونریزی شد",
+ "importedLog": "مدلی با {{nodes}} گره درونریزی شد.",
+ "importFailed": "درونریزی ناموفق بود",
+ "importInvalid": "این پرونده شامل گراف مدل نیست.",
+ "importUnsupported": "این پرونده یک مدل GeoLibre نیست.",
+ "rasterOutputUnsupported": "«{{name}}» یک نتیجهٔ رستری است که این نسخه نمیتواند به نقشه بیفزاید.",
+ "portNeedsVector": "«{{port}}» به دادهٔ برداری نیاز دارد، اما یک رستر دریافت شد.",
+ "toolNoOutput": "«{{tool}}» هیچ خروجی تولید نکرد.",
+ "toolNoUsableOutput": "«{{tool}}» خروجی قابلاستفادهای تولید نکرد."
},
"parameterField": {
"selectLayer": "یک لایه برگزینید...",
@@ -5485,7 +5551,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 873678dae7..87a3eed93e 100644
--- a/apps/geolibre-desktop/src/i18n/locales/fr.json
+++ b/apps/geolibre-desktop/src/i18n/locales/fr.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,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",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Exécuter le lot",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Charger un modèle enregistré...",
"deleteModel": "Supprimer",
- "inputPreviousStep": "Entrée : ← sortie de l'étape précédente",
- "unknownTool": "Outil inconnu « {{id}} »",
- "noParameters": "Aucun paramètre."
+ "deletedLog": "Modèle supprimé du projet.",
+ "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",
+ "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",
+ "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.",
+ "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.",
+ "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.",
+ "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...",
@@ -5485,7 +5551,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 13cb0a3f48..b4c5dc8083 100644
--- a/apps/geolibre-desktop/src/i18n/locales/hi.json
+++ b/apps/geolibre-desktop/src/i18n/locales/hi.json
@@ -2216,7 +2216,8 @@
"dashboard": "डैशबोर्ड",
"assistant": "AI सहायक",
"geocode": "पते जियोकोड करें",
- "modelBuilder": "बैच और मॉडल",
+ "batchTools": "बैच उपकरण",
+ "modelBuilder": "मॉडल बिल्डर",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "AI सेगमेंटेशन",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "DuckDB परत",
"whitebox": "Whitebox",
"geocode": "पते जियोकोड करें",
- "modelBuilder": "बैच और मॉडल",
+ "modelBuilder": "मॉडल बिल्डर",
"processingHistory": "इतिहास",
"conversion": "रूपांतरण",
"vector": "वेक्टर",
@@ -2711,7 +2712,8 @@
"projectName": "प्रोजेक्ट नाम",
"storymapEllipsis": "स्टोरी मैप...",
"pointerElevationNoticeTitle": "ऊँचाई सार्वजनिक सेवा का उपयोग करती है",
- "pointerElevationNoticeDesc": "3D भूभाग उपलब्ध होने पर ऊँचाई मानचित्र से ही निकाली जाती है और कुछ भी नहीं भेजा जाता। 3D भूभाग उपलब्ध न होने पर सार्वजनिक Open-Meteo API से ऊँचाई प्राप्त की जाती है और पॉइंटर के नीचे के निर्देशांक आपके डिवाइस से बाहर जाते हैं।"
+ "pointerElevationNoticeDesc": "3D भूभाग उपलब्ध होने पर ऊँचाई मानचित्र से ही निकाली जाती है और कुछ भी नहीं भेजा जाता। 3D भूभाग उपलब्ध न होने पर सार्वजनिक Open-Meteo API से ऊँचाई प्राप्त की जाती है और पॉइंटर के नीचे के निर्देशांक आपके डिवाइस से बाहर जाते हैं।",
+ "batchTools": "बैच उपकरण"
},
"plugin": {
"maplibre-gl-annotations": "एनोटेशन",
@@ -4029,33 +4031,97 @@
"count_other": "{{count}} रन दर्ज",
"toolUnavailable": "टूल \"{{toolId}}\" अब उपलब्ध नहीं है"
},
- "modelBuilder": {
- "moveStepUp": "चरण ऊपर ले जाएं",
- "moveStepDown": "चरण नीचे ले जाएं",
- "removeStep": "चरण हटाएं",
- "title": "बैच और मॉडल",
- "description": "कई लेयर पर एक वेक्टर टूल चलाएँ, या टूल को जोड़कर एक पुन: प्रयोज्य मॉडल बनाएँ जो आपकी परियोजना के साथ सहेजा जाता है।",
- "tabBatch": "बैच",
- "tabModels": "मॉडल",
- "outputPlaceholder": "आउटपुट यहाँ दिखाई देगा।",
+ "batchTools": {
+ "title": "बैच उपकरण",
+ "description": "एक ही वेक्टर उपकरण को कई परतों पर एक साथ चलाएँ।",
+ "runBatch": "बैच चलाएँ",
"tool": "टूल",
"sharedParameters": "साझा पैरामीटर",
"noExtraParameters": "इस टूल में कोई अतिरिक्त पैरामीटर नहीं है।",
"inputLayers": "इनपुट लेयर",
- "selectAll": "सभी चुनें",
"clearSelection": "साफ़ करें",
+ "selectAll": "सभी चुनें",
"noCompatibleLayers": "कोई संगत GeoJSON लेयर नहीं।",
- "newModel": "नया मॉडल",
- "noSavedModels": "अभी तक कोई सहेजा गया मॉडल नहीं।",
+ "outputPlaceholder": "आउटपुट यहाँ दिखाई देगा।"
+ },
+ "modelBuilder": {
+ "title": "मॉडल बिल्डर",
+ "description": "उपकरणों को कैनवास पर खींचें और उन्हें जोड़कर एक प्रोसेसिंग मॉडल बनाएँ।",
+ "modelName": "मॉडल नाम",
+ "modelNamePlaceholder": "बिना शीर्षक मॉडल",
"untitledModel": "बिना शीर्षक मॉडल",
- "modelName": "मॉडल का नाम",
- "emptyPipelineHint": "पाइपलाइन बनाना शुरू करने के लिए एक चरण जोड़ें। पहला चरण एक इनपुट लेयर पढ़ता है; उसके बाद हर चरण पिछले चरण का आउटपुट लेता है।",
- "addStep": "चरण जोड़ें",
- "runModel": "मॉडल चलाएँ",
+ "newModel": "नया",
+ "discardChanges": "वर्तमान मॉडल में असेव्ड बदलाव त्यागें?",
+ "arrange": "व्यवस्थित करें",
+ "arrangeHint": "नोड्स को प्रवाह के अनुसार व्यवस्थित करें",
+ "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": "सहेजे गए मॉडल",
+ "loadModelPlaceholder": "सहेजा गया मॉडल लोड करें...",
"deleteModel": "हटाएँ",
- "inputPreviousStep": "इनपुट: ← पिछले चरण का आउटपुट",
- "unknownTool": "अज्ञात टूल \"{{id}}\"",
- "noParameters": "कोई पैरामीटर नहीं।"
+ "deletedLog": "मॉडल परियोजना से हटाया गया।",
+ "searchTools": "उपकरण खोजें",
+ "loadingTools": "उपकरण लोड हो रहे हैं...",
+ "noToolsMatch": "आपकी खोज से कोई उपकरण मेल नहीं खाता।",
+ "addInputNode": "+ इनपुट",
+ "addOutputNode": "+ आउटपुट",
+ "canvasEmpty": "बनाना शुरू करने के लिए पैलेट से कोई उपकरण खींचें।",
+ "inputNode": "इनपुट",
+ "outputNode": "आउटपुट",
+ "inputPort": "इनपुट: {{port}}",
+ "outputPort": "आउटपुट: {{port}}",
+ "removeConnection": "कनेक्शन हटाएँ",
+ "removeNode": "नोड हटाएँ",
+ "resizePanel": "पैनल का आकार बदलें",
+ "minimizePanel": "पैनल छोटा करें",
+ "restorePanel": "पैनल पुनर्स्थापित करें",
+ "resizePalette": "उपकरण पैलेट का आकार बदलें",
+ "resizeInspector": "गुण पैनल का आकार बदलें",
+ "resizeLog": "संदेश लॉग का आकार बदलें",
+ "selectNodeHint": "सेटिंग्स संपादित करने के लिए कोई नोड चुनें।",
+ "sourceLayer": "स्रोत परत",
+ "chooseLayer": "एक परत चुनें...",
+ "resultName": "परिणाम नाम",
+ "resultNamePlaceholder": "मॉडल आउटपुट",
+ "noParameters": "कोई पैरामीटर नहीं।",
+ "keepResultHint": "किसी मध्यवर्ती परिणाम के लिए आउटपुट जोड़कर उसे सहेजें।",
+ "keepResultSingle": "यह परिणाम सहेजें",
+ "resultKeptSingle": "यह परिणाम सहेजा गया है",
+ "keepResult": "\"{{port}}\" सहेजें",
+ "resultKept": "\"{{port}}\" सहेजा गया है",
+ "outputPlaceholder": "संदेश यहाँ दिखाई देंगे।",
+ "connectCycle": "वह कनेक्शन एक लूप बना देगा।",
+ "connectSameNode": "कोई नोड स्वयं से नहीं जुड़ सकता।",
+ "fixIssuesFirst": "चलाने से पहले बताई गई समस्याएँ ठीक करें।",
+ "runFailed": "चलाना विफल रहा",
+ "runFinished": "चलना पूरा हुआ — {{outputs}} आउटपुट जोड़े गए।",
+ "savedLog": "मॉडल परियोजना में सहेजा गया।",
+ "exportedLog": "{{name}} निर्यात किया गया",
+ "importedLog": "{{nodes}} नोड वाला मॉडल आयात किया गया।",
+ "importFailed": "आयात विफल रहा",
+ "importInvalid": "उस फ़ाइल में मॉडल ग्राफ़ नहीं है।",
+ "importUnsupported": "वह फ़ाइल GeoLibre मॉडल नहीं है।",
+ "rasterOutputUnsupported": "\"{{name}}\" एक रास्टर परिणाम है, जिसे यह बिल्ड मानचित्र में नहीं जोड़ सकता।",
+ "portNeedsVector": "\"{{port}}\" को वेक्टर डेटा चाहिए, लेकिन रास्टर मिला।",
+ "toolNoOutput": "\"{{tool}}\" ने कोई आउटपुट नहीं बनाया।",
+ "toolNoUsableOutput": "\"{{tool}}\" ने कोई उपयोगी आउटपुट नहीं बनाया।"
},
"parameterField": {
"selectLayer": "एक लेयर चुनें...",
@@ -5485,7 +5551,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 984195181f..1edda158e8 100644
--- a/apps/geolibre-desktop/src/i18n/locales/id.json
+++ b/apps/geolibre-desktop/src/i18n/locales/id.json
@@ -2171,7 +2171,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",
@@ -2499,7 +2500,7 @@
"duckdbLayer": "Layer DuckDB",
"whitebox": "Whitebox",
"geocode": "Geocode Alamat",
- "modelBuilder": "Batch & Model",
+ "modelBuilder": "Pembuat Model",
"processingHistory": "Riwayat",
"conversion": "Konversi",
"vector": "Vektor",
@@ -2663,7 +2664,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",
@@ -3962,33 +3964,97 @@
"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.",
+ "runBatch": "Jalankan massal",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Muat model tersimpan...",
"deleteModel": "Hapus",
- "inputPreviousStep": "Masukan: ← keluaran langkah sebelumnya",
- "unknownTool": "Alat tidak dikenal \"{{id}}\"",
- "noParameters": "Tidak ada parameter."
+ "deletedLog": "Model dihapus dari proyek.",
+ "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",
+ "minimizePanel": "Perkecil panel",
+ "restorePanel": "Pulihkan 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.",
+ "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.",
+ "importUnsupported": "Berkas itu bukan model GeoLibre.",
+ "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...",
@@ -5405,7 +5471,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 536083ddee..4877694d49 100644
--- a/apps/geolibre-desktop/src/i18n/locales/it.json
+++ b/apps/geolibre-desktop/src/i18n/locales/it.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "Livello DuckDB",
"whitebox": "Whitebox",
"geocode": "Geocodifica indirizzi",
- "modelBuilder": "Batch e modelli",
+ "modelBuilder": "Generatore di modelli",
"processingHistory": "Cronologia",
"conversion": "Conversione",
"vector": "Vettoriale",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Esegui in blocco",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Carica un modello salvato...",
"deleteModel": "Elimina",
- "inputPreviousStep": "Input: ← output del passaggio precedente",
- "unknownTool": "Strumento sconosciuto «{{id}}»",
- "noParameters": "Nessun parametro."
+ "deletedLog": "Modello eliminato dal progetto.",
+ "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",
+ "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",
+ "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.",
+ "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.",
+ "importUnsupported": "Quel file non è un modello GeoLibre.",
+ "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...",
@@ -5485,7 +5551,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 a508bc4807..a8fd6521eb 100644
--- a/apps/geolibre-desktop/src/i18n/locales/ja.json
+++ b/apps/geolibre-desktop/src/i18n/locales/ja.json
@@ -2171,7 +2171,8 @@
"dashboard": "ダッシュボード",
"assistant": "AIアシスタント",
"geocode": "住所をジオコーディング",
- "modelBuilder": "バッチ処理とモデル",
+ "batchTools": "バッチツール",
+ "modelBuilder": "モデルビルダー",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "AIセグメンテーション",
@@ -2499,7 +2500,7 @@
"duckdbLayer": "DuckDB レイヤー",
"whitebox": "Whitebox",
"geocode": "住所をジオコーディング",
- "modelBuilder": "バッチ処理とモデル",
+ "modelBuilder": "モデルビルダー",
"processingHistory": "履歴",
"conversion": "変換",
"vector": "ベクター",
@@ -2663,7 +2664,8 @@
"projectName": "プロジェクト名",
"storymapEllipsis": "ストーリーマップ...",
"pointerElevationNoticeTitle": "標高は公開サービスを利用します",
- "pointerElevationNoticeDesc": "3D地形が有効な場合、標高は地図自体から求められ、何も送信されません。3D地形がない場合は公開のOpen-Meteo APIに問い合わせ、ポインター位置の座標が端末外に送信されます。"
+ "pointerElevationNoticeDesc": "3D地形が有効な場合、標高は地図自体から求められ、何も送信されません。3D地形がない場合は公開のOpen-Meteo APIに問い合わせ、ポインター位置の座標が端末外に送信されます。",
+ "batchTools": "バッチツール"
},
"plugin": {
"maplibre-gl-annotations": "注釈",
@@ -3962,33 +3964,97 @@
"count_other": "{{count}} 件の実行を記録",
"toolUnavailable": "ツール「{{toolId}}」は利用できなくなりました"
},
- "modelBuilder": {
- "moveStepUp": "ステップを上に移動",
- "moveStepDown": "ステップを下に移動",
- "removeStep": "ステップを削除",
- "title": "バッチとモデル",
- "description": "1 つのベクターツールを多数のレイヤーに対して実行するか、ツールを連結してプロジェクトとともに保存できる再利用可能なモデルを作成します。",
- "tabBatch": "バッチ",
- "tabModels": "モデル",
- "outputPlaceholder": "ここに出力が表示されます。",
+ "batchTools": {
+ "title": "バッチツール",
+ "description": "1 つのベクターツールを多数のレイヤーに一括で実行します。",
+ "runBatch": "バッチを実行",
"tool": "ツール",
"sharedParameters": "共通パラメータ",
"noExtraParameters": "このツールに追加のパラメータはありません。",
"inputLayers": "入力レイヤー",
- "selectAll": "すべて選択",
"clearSelection": "選択解除",
+ "selectAll": "すべて選択",
"noCompatibleLayers": "対応する GeoJSON レイヤーがありません。",
- "newModel": "新しいモデル",
- "noSavedModels": "保存されたモデルはまだありません。",
- "untitledModel": "無題のモデル",
+ "outputPlaceholder": "ここに出力が表示されます。"
+ },
+ "modelBuilder": {
+ "title": "モデルビルダー",
+ "description": "ツールをキャンバスにドラッグし、つなげて処理モデルを作成します。",
"modelName": "モデル名",
- "emptyPipelineHint": "ステップを追加してパイプラインの作成を始めましょう。最初のステップは入力レイヤーを読み込み、以降の各ステップは前のステップの出力を受け取ります。",
- "addStep": "ステップを追加",
- "runModel": "モデルを実行",
+ "modelNamePlaceholder": "名称未設定のモデル",
+ "untitledModel": "名称未設定のモデル",
+ "newModel": "新規",
+ "discardChanges": "現在のモデルの未保存の変更を破棄しますか?",
+ "arrange": "整列",
+ "arrangeHint": "ノードを処理の流れに沿って並べます",
+ "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": "保存済みのモデル",
+ "loadModelPlaceholder": "保存済みモデルを読み込む…",
"deleteModel": "削除",
- "inputPreviousStep": "入力: ← 前のステップの出力",
- "unknownTool": "不明なツール「{{id}}」",
- "noParameters": "パラメータはありません。"
+ "deletedLog": "モデルをプロジェクトから削除しました。",
+ "searchTools": "ツールを検索",
+ "loadingTools": "ツールを読み込んでいます…",
+ "noToolsMatch": "検索に一致するツールはありません。",
+ "addInputNode": "+ 入力",
+ "addOutputNode": "+ 出力",
+ "canvasEmpty": "パレットからツールをドラッグして作成を始めます。",
+ "inputNode": "入力",
+ "outputNode": "出力",
+ "inputPort": "入力: {{port}}",
+ "outputPort": "出力: {{port}}",
+ "removeConnection": "接続を削除",
+ "removeNode": "ノードを削除",
+ "resizePanel": "パネルのサイズを変更",
+ "minimizePanel": "パネルを最小化",
+ "restorePanel": "パネルを元に戻す",
+ "resizePalette": "ツールパレットのサイズを変更",
+ "resizeInspector": "プロパティパネルのサイズを変更",
+ "resizeLog": "メッセージログのサイズを変更",
+ "selectNodeHint": "ノードを選択すると設定を編集できます。",
+ "sourceLayer": "ソースレイヤー",
+ "chooseLayer": "レイヤーを選択…",
+ "resultName": "結果名",
+ "resultNamePlaceholder": "モデル出力",
+ "noParameters": "パラメータはありません。",
+ "keepResultHint": "出力を追加すると、途中の結果も残せます。",
+ "keepResultSingle": "この結果を残す",
+ "resultKeptSingle": "この結果は残されます",
+ "keepResult": "「{{port}}」を残す",
+ "resultKept": "「{{port}}」は残されます",
+ "outputPlaceholder": "メッセージはここに表示されます。",
+ "connectCycle": "その接続はループを作成します。",
+ "connectSameNode": "ノードを自分自身に接続することはできません。",
+ "fixIssuesFirst": "実行する前に報告された問題を修正してください。",
+ "runFailed": "実行に失敗しました",
+ "runFinished": "実行が完了しました — {{outputs}} 件の出力を追加しました。",
+ "savedLog": "モデルをプロジェクトに保存しました。",
+ "exportedLog": "{{name}} をエクスポートしました",
+ "importedLog": "{{nodes}} 個のノードを持つモデルをインポートしました。",
+ "importFailed": "インポートに失敗しました",
+ "importInvalid": "このファイルにはモデルグラフが含まれていません。",
+ "importUnsupported": "このファイルは GeoLibre のモデルではありません。",
+ "rasterOutputUnsupported": "「{{name}}」はラスター結果のため、このビルドでは地図に追加できません。",
+ "portNeedsVector": "「{{port}}」にはベクターデータが必要ですが、ラスターが渡されました。",
+ "toolNoOutput": "「{{tool}}」は出力を生成しませんでした。",
+ "toolNoUsableOutput": "「{{tool}}」は利用できる出力を生成しませんでした。"
},
"parameterField": {
"selectLayer": "レイヤーを選択...",
@@ -5405,7 +5471,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 42613862b7..8de41c14b2 100644
--- a/apps/geolibre-desktop/src/i18n/locales/ka.json
+++ b/apps/geolibre-desktop/src/i18n/locales/ka.json
@@ -2216,7 +2216,8 @@
"dashboard": "დაფა",
"assistant": "AI ასისტენტი",
"geocode": "მისამართების გეოკოდირება",
- "modelBuilder": "პაკეტები და მოდელები",
+ "batchTools": "სერიული ხელსაწყოები",
+ "modelBuilder": "მოდელის შემქმნელი",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "AI სეგმენტაცია",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "DuckDB შრე",
"whitebox": "Whitebox",
"geocode": "მისამართების გეოკოდირება",
- "modelBuilder": "პაკეტური & მოდელები",
+ "modelBuilder": "მოდელის შემქმნელი",
"processingHistory": "ისტორია",
"conversion": "კონვერტაცია",
"vector": "ვექტორი",
@@ -2711,7 +2712,8 @@
"projectName": "პროექტის სახელი",
"storymapEllipsis": "Story Map...",
"pointerElevationNoticeTitle": "სიმაღლე იყენებს საჯარო სერვისს",
- "pointerElevationNoticeDesc": "3D რელიეფის ჩართვისას სიმაღლე გამოითვლება თავად რუკიდან და არაფერი იგზავნება. მის გარეშე გამოიყენება საჯარო Open-Meteo API და კურსორის კოორდინატები ტოვებს თქვენს მოწყობილობას."
+ "pointerElevationNoticeDesc": "3D რელიეფის ჩართვისას სიმაღლე გამოითვლება თავად რუკიდან და არაფერი იგზავნება. მის გარეშე გამოიყენება საჯარო Open-Meteo API და კურსორის კოორდინატები ტოვებს თქვენს მოწყობილობას.",
+ "batchTools": "სერიული ხელსაწყოები"
},
"plugin": {
"maplibre-gl-annotations": "ანოტაციები",
@@ -4029,33 +4031,97 @@
"count_other": "ჩაწერილია {{count}} გაშვება",
"toolUnavailable": "ხელსაწყო \"{{toolId}}\" აღარ არის ხელმისაწვდომი"
},
- "modelBuilder": {
- "moveStepUp": "ნაბიჯის აწევა",
- "moveStepDown": "ნაბიჯის დაწევა",
- "removeStep": "ნაბიჯის წაშლა",
- "title": "პაკეტური დამუშავება და მოდელები",
- "description": "გაუშვით ვექტორული ხელსაწყო მრავალ შრეზე, ან დააკავშირეთ ხელსაწყოები თქვენს პროექტთან ერთად შენახულ მრავალჯერად მოდელში.",
- "tabBatch": "პაკეტი",
- "tabModels": "მოდელები",
- "outputPlaceholder": "შედეგი აქ გამოჩნდება.",
+ "batchTools": {
+ "title": "სერიული ხელსაწყოები",
+ "description": "ერთი ვექტორული ხელსაწყოს გაშვება მრავალ ფენაზე ერთდროულად.",
+ "runBatch": "სერიის გაშვება",
"tool": "ხელსაწყო",
"sharedParameters": "საერთო პარამეტრები",
"noExtraParameters": "ამ ხელსაწყოს დამატებითი პარამეტრები არ აქვს.",
"inputLayers": "შემავალი შრეები",
- "selectAll": "ყველას მონიშვნა",
"clearSelection": "გასუფთავება",
+ "selectAll": "ყველას მონიშვნა",
"noCompatibleLayers": "თავსებადი GeoJSON შრეები არ არის.",
- "newModel": "ახალი მოდელი",
- "noSavedModels": "შენახული მოდელები ჯერ არ არის.",
- "untitledModel": "უსახელო მოდელი",
+ "outputPlaceholder": "შედეგი აქ გამოჩნდება."
+ },
+ "modelBuilder": {
+ "title": "მოდელის შემქმნელი",
+ "description": "გადმოიტანეთ ხელსაწყოები ტილოზე და დააკავშირეთ ისინი დამუშავების მოდელად.",
"modelName": "მოდელის სახელი",
- "emptyPipelineHint": "დაამატეთ ნაბიჯი კონვეიერის ასაგებად. პირველი ნაბიჯი კითხულობს შემავალ შრეს; ყოველი შემდეგი ნაბიჯი იღებს წინა ნაბიჯის შედეგს.",
- "addStep": "ნაბიჯის დამატება",
- "runModel": "მოდელის გაშვება",
+ "modelNamePlaceholder": "უსათაურო მოდელი",
+ "untitledModel": "უსათაურო მოდელი",
+ "newModel": "ახალი",
+ "discardChanges": "უარვყოთ მიმდინარე მოდელის შეუნახავი ცვლილებები?",
+ "arrange": "დალაგება",
+ "arrangeHint": "კვანძების დალაგება ნაკადის მიმართულებით",
+ "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": "შენახული მოდელები",
+ "loadModelPlaceholder": "შენახული მოდელის ჩატვირთვა...",
"deleteModel": "წაშლა",
- "inputPreviousStep": "შესატანი: ← წინა ნაბიჯის შედეგი",
- "unknownTool": "უცნობი ხელსაწყო „{{id}}“",
- "noParameters": "პარამეტრები არ არის."
+ "deletedLog": "მოდელი წაშლილია პროექტიდან.",
+ "searchTools": "ხელსაწყოების ძებნა",
+ "loadingTools": "ხელსაწყოები იტვირთება...",
+ "noToolsMatch": "თქვენს ძებნას ხელსაწყო არ ემთხვევა.",
+ "addInputNode": "+ შემავალი",
+ "addOutputNode": "+ გამომავალი",
+ "canvasEmpty": "დასაწყებად გადმოიტანეთ ხელსაწყო პალიტრიდან.",
+ "inputNode": "შემავალი",
+ "outputNode": "გამომავალი",
+ "inputPort": "შემავალი: {{port}}",
+ "outputPort": "გამომავალი: {{port}}",
+ "removeConnection": "კავშირის წაშლა",
+ "removeNode": "კვანძის წაშლა",
+ "resizePanel": "პანელის ზომის შეცვლა",
+ "minimizePanel": "პანელის ჩაკეცვა",
+ "restorePanel": "პანელის აღდგენა",
+ "resizePalette": "ხელსაწყოთა პალიტრის ზომის შეცვლა",
+ "resizeInspector": "თვისებების პანელის ზომის შეცვლა",
+ "resizeLog": "შეტყობინებების ჟურნალის ზომის შეცვლა",
+ "selectNodeHint": "აირჩიეთ კვანძი მისი პარამეტრების შესაცვლელად.",
+ "sourceLayer": "წყაროს ფენა",
+ "chooseLayer": "აირჩიეთ ფენა...",
+ "resultName": "შედეგის სახელი",
+ "resultNamePlaceholder": "მოდელის გამომავალი",
+ "noParameters": "პარამეტრები არ არის.",
+ "keepResultHint": "შუალედური შედეგის შესანარჩუნებლად დაამატეთ მისთვის გამოსავალი.",
+ "keepResultSingle": "ამ შედეგის შენარჩუნება",
+ "resultKeptSingle": "ეს შედეგი შენარჩუნებულია",
+ "keepResult": "„{{port}}“-ის შენარჩუნება",
+ "resultKept": "„{{port}}“ შენარჩუნებულია",
+ "outputPlaceholder": "შეტყობინებები აქ გამოჩნდება.",
+ "connectCycle": "ეს კავშირი მარყუჟს შექმნის.",
+ "connectSameNode": "კვანძი საკუთარ თავს ვერ დაუკავშირდება.",
+ "fixIssuesFirst": "გაშვებამდე გამოასწორეთ მითითებული პრობლემები.",
+ "runFailed": "გაშვება ვერ მოხერხდა",
+ "runFinished": "გაშვება დასრულდა — დაემატა {{outputs}} გამომავალი.",
+ "savedLog": "მოდელი შენახულია პროექტში.",
+ "exportedLog": "{{name}} ექსპორტირებულია",
+ "importedLog": "იმპორტირებულია მოდელი {{nodes}} კვანძით.",
+ "importFailed": "იმპორტი ვერ მოხერხდა",
+ "importInvalid": "ეს ფაილი მოდელის გრაფს არ შეიცავს.",
+ "importUnsupported": "ეს ფაილი GeoLibre-ის მოდელი არ არის.",
+ "rasterOutputUnsupported": "„{{name}}“ არის რასტრული შედეგი, რომელსაც ეს ბილდი რუკაზე ვერ დაამატებს.",
+ "portNeedsVector": "„{{port}}“ საჭიროებს ვექტორულ მონაცემს, მაგრამ მივიდა რასტრი.",
+ "toolNoOutput": "„{{tool}}“-მა შედეგი ვერ დააბრუნა.",
+ "toolNoUsableOutput": "„{{tool}}“-მა გამოსადეგი შედეგი ვერ დააბრუნა."
},
"parameterField": {
"selectLayer": "აირჩიეთ ფენა...",
@@ -5485,7 +5551,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 34566f7c01..7af105351d 100644
--- a/apps/geolibre-desktop/src/i18n/locales/ko.json
+++ b/apps/geolibre-desktop/src/i18n/locales/ko.json
@@ -2171,7 +2171,8 @@
"dashboard": "대시보드",
"assistant": "AI 어시스턴트",
"geocode": "주소 지오코딩",
- "modelBuilder": "배치 및 모델",
+ "batchTools": "일괄 도구",
+ "modelBuilder": "모델 빌더",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "AI 분할",
@@ -2499,7 +2500,7 @@
"duckdbLayer": "DuckDB 레이어",
"whitebox": "Whitebox",
"geocode": "주소 지오코딩",
- "modelBuilder": "배치 및 모델",
+ "modelBuilder": "모델 빌더",
"processingHistory": "기록",
"conversion": "변환",
"vector": "벡터",
@@ -2663,7 +2664,8 @@
"projectName": "프로젝트 이름",
"storymapEllipsis": "스토리 맵...",
"pointerElevationNoticeTitle": "고도는 공개 서비스를 사용합니다",
- "pointerElevationNoticeDesc": "3D 지형이 켜져 있으면 고도는 지도 자체에서 계산되며 아무것도 전송되지 않습니다. 3D 지형이 없으면 공개 Open-Meteo API를 조회하며 포인터 아래 좌표가 기기를 벗어납니다."
+ "pointerElevationNoticeDesc": "3D 지형이 켜져 있으면 고도는 지도 자체에서 계산되며 아무것도 전송되지 않습니다. 3D 지형이 없으면 공개 Open-Meteo API를 조회하며 포인터 아래 좌표가 기기를 벗어납니다.",
+ "batchTools": "일괄 도구"
},
"plugin": {
"maplibre-gl-annotations": "주석",
@@ -3962,33 +3964,97 @@
"count_other": "{{count}}개 실행 기록됨",
"toolUnavailable": "도구 \"{{toolId}}\"을(를) 더 이상 사용할 수 없습니다"
},
- "modelBuilder": {
- "moveStepUp": "단계 위로 이동",
- "moveStepDown": "단계 아래로 이동",
- "removeStep": "단계 제거",
- "title": "일괄 처리 및 모델",
- "description": "여러 레이어에 벡터 도구를 실행하거나, 도구를 연결해 프로젝트와 함께 저장되는 재사용 가능한 모델을 만듭니다.",
- "tabBatch": "일괄 처리",
- "tabModels": "모델",
- "outputPlaceholder": "여기에 출력이 표시됩니다.",
+ "batchTools": {
+ "title": "일괄 도구",
+ "description": "하나의 벡터 도구를 여러 레이어에 한 번에 실행합니다.",
+ "runBatch": "일괄 실행",
"tool": "도구",
"sharedParameters": "공통 매개변수",
"noExtraParameters": "이 도구에는 추가 매개변수가 없습니다.",
"inputLayers": "입력 레이어",
- "selectAll": "모두 선택",
"clearSelection": "선택 해제",
+ "selectAll": "모두 선택",
"noCompatibleLayers": "호환되는 GeoJSON 레이어가 없습니다.",
- "newModel": "새 모델",
- "noSavedModels": "저장된 모델이 아직 없습니다.",
- "untitledModel": "제목 없는 모델",
+ "outputPlaceholder": "여기에 출력이 표시됩니다."
+ },
+ "modelBuilder": {
+ "title": "모델 빌더",
+ "description": "도구를 캔버스로 끌어다 놓고 서로 연결하여 처리 모델을 만듭니다.",
"modelName": "모델 이름",
- "emptyPipelineHint": "단계를 추가해 파이프라인을 구성하세요. 첫 단계는 입력 레이어를 읽고, 이후 각 단계는 이전 단계의 출력을 받습니다.",
- "addStep": "단계 추가",
- "runModel": "모델 실행",
+ "modelNamePlaceholder": "제목 없는 모델",
+ "untitledModel": "제목 없는 모델",
+ "newModel": "새로 만들기",
+ "discardChanges": "현재 모델의 저장되지 않은 변경 사항을 취소하시겠습니까?",
+ "arrange": "정렬",
+ "arrangeHint": "노드를 처리 흐름에 따라 배치합니다",
+ "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": "저장된 모델",
+ "loadModelPlaceholder": "저장된 모델 불러오기...",
"deleteModel": "삭제",
- "inputPreviousStep": "입력: ← 이전 단계의 출력",
- "unknownTool": "알 수 없는 도구 \"{{id}}\"",
- "noParameters": "매개변수가 없습니다."
+ "deletedLog": "모델을 프로젝트에서 삭제했습니다.",
+ "searchTools": "도구 검색",
+ "loadingTools": "도구를 불러오는 중...",
+ "noToolsMatch": "검색과 일치하는 도구가 없습니다.",
+ "addInputNode": "+ 입력",
+ "addOutputNode": "+ 출력",
+ "canvasEmpty": "팔레트에서 도구를 끌어다 놓아 시작하세요.",
+ "inputNode": "입력",
+ "outputNode": "출력",
+ "inputPort": "입력: {{port}}",
+ "outputPort": "출력: {{port}}",
+ "removeConnection": "연결 제거",
+ "removeNode": "노드 제거",
+ "resizePanel": "패널 크기 조정",
+ "minimizePanel": "패널 최소화",
+ "restorePanel": "패널 복원",
+ "resizePalette": "도구 팔레트 크기 조정",
+ "resizeInspector": "속성 패널 크기 조정",
+ "resizeLog": "메시지 로그 크기 조정",
+ "selectNodeHint": "노드를 선택하면 설정을 편집할 수 있습니다.",
+ "sourceLayer": "원본 레이어",
+ "chooseLayer": "레이어 선택...",
+ "resultName": "결과 이름",
+ "resultNamePlaceholder": "모델 출력",
+ "noParameters": "매개변수가 없습니다.",
+ "keepResultHint": "출력을 추가하면 중간 결과도 남길 수 있습니다.",
+ "keepResultSingle": "이 결과 남기기",
+ "resultKeptSingle": "이 결과를 남김",
+ "keepResult": "\"{{port}}\" 남기기",
+ "resultKept": "\"{{port}}\" 남김",
+ "outputPlaceholder": "메시지가 여기에 표시됩니다.",
+ "connectCycle": "그 연결은 순환을 만듭니다.",
+ "connectSameNode": "노드는 자기 자신에 연결할 수 없습니다.",
+ "fixIssuesFirst": "실행하기 전에 보고된 문제를 해결하세요.",
+ "runFailed": "실행 실패",
+ "runFinished": "실행 완료 — 출력 {{outputs}}개를 추가했습니다.",
+ "savedLog": "모델을 프로젝트에 저장했습니다.",
+ "exportedLog": "{{name}}을(를) 내보냈습니다",
+ "importedLog": "노드 {{nodes}}개인 모델을 가져왔습니다.",
+ "importFailed": "가져오기 실패",
+ "importInvalid": "해당 파일에는 모델 그래프가 없습니다.",
+ "importUnsupported": "해당 파일은 GeoLibre 모델이 아닙니다.",
+ "rasterOutputUnsupported": "\"{{name}}\"은(는) 래스터 결과이며 이 빌드에서는 지도에 추가할 수 없습니다.",
+ "portNeedsVector": "\"{{port}}\"에는 벡터 데이터가 필요하지만 래스터가 전달되었습니다.",
+ "toolNoOutput": "\"{{tool}}\"이(가) 출력을 생성하지 않았습니다.",
+ "toolNoUsableOutput": "\"{{tool}}\"이(가) 사용할 수 있는 출력을 생성하지 않았습니다."
},
"parameterField": {
"selectLayer": "레이어 선택...",
@@ -5405,7 +5471,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 ad389ad1f3..d8cf7786b6 100644
--- a/apps/geolibre-desktop/src/i18n/locales/nl.json
+++ b/apps/geolibre-desktop/src/i18n/locales/nl.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,7 @@
"duckdbLayer": "DuckDB-laag",
"whitebox": "Whitebox",
"geocode": "Adressen geocoderen",
- "modelBuilder": "Batches & modellen",
+ "modelBuilder": "Modelbouwer",
"processingHistory": "Geschiedenis",
"conversion": "Conversie",
"vector": "Vector",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Batch 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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Een opgeslagen model laden...",
"deleteModel": "Verwijderen",
- "inputPreviousStep": "Invoer: ← uitvoer van de vorige stap",
- "unknownTool": "Onbekend gereedschap ‘{{id}}’",
- "noParameters": "Geen parameters."
+ "deletedLog": "Model verwijderd uit het project.",
+ "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",
+ "minimizePanel": "Paneel minimaliseren",
+ "restorePanel": "Paneel herstellen",
+ "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.",
+ "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.",
+ "importUnsupported": "Dat bestand is geen GeoLibre-model.",
+ "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...",
@@ -5485,7 +5551,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 dd44ca9e0d..b3fa92c910 100644
--- a/apps/geolibre-desktop/src/i18n/locales/pt.json
+++ b/apps/geolibre-desktop/src/i18n/locales/pt.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,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",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Executar lote",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Carregar um modelo guardado...",
"deleteModel": "Excluir",
- "inputPreviousStep": "Entrada: ← saída da etapa anterior",
- "unknownTool": "Ferramenta desconhecida “{{id}}”",
- "noParameters": "Sem parâmetros."
+ "deletedLog": "Modelo eliminado do projeto.",
+ "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",
+ "minimizePanel": "Minimizar painel",
+ "restorePanel": "Restaurar 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.",
+ "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.",
+ "importUnsupported": "Esse ficheiro não é um modelo do GeoLibre.",
+ "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...",
@@ -5485,7 +5551,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 a1e617dc9f..47dc24524f 100644
--- a/apps/geolibre-desktop/src/i18n/locales/ru.json
+++ b/apps/geolibre-desktop/src/i18n/locales/ru.json
@@ -2306,7 +2306,8 @@
"dashboard": "Панель мониторинга",
"assistant": "ИИ-ассистент",
"geocode": "Геокодировать адреса",
- "modelBuilder": "Пакеты и модели",
+ "batchTools": "Пакетные инструменты",
+ "modelBuilder": "Конструктор моделей",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "ИИ-сегментация",
@@ -2634,7 +2635,7 @@
"duckdbLayer": "Слой DuckDB",
"whitebox": "Whitebox",
"geocode": "Геокодировать адреса",
- "modelBuilder": "Пакеты и модели",
+ "modelBuilder": "Конструктор моделей",
"processingHistory": "История",
"conversion": "Конвертация",
"vector": "Вектор",
@@ -2807,7 +2808,8 @@
"projectName": "Имя проекта",
"storymapEllipsis": "История на карте...",
"pointerElevationNoticeTitle": "Высота использует публичный сервис",
- "pointerElevationNoticeDesc": "При включённом 3D-рельефе высота вычисляется по самой карте и ничего не отправляется. Без 3D-рельефа запрашивается публичный Open-Meteo API, и координаты под указателем покидают ваше устройство."
+ "pointerElevationNoticeDesc": "При включённом 3D-рельефе высота вычисляется по самой карте и ничего не отправляется. Без 3D-рельефа запрашивается публичный Open-Meteo API, и координаты под указателем покидают ваше устройство.",
+ "batchTools": "Пакетные инструменты"
},
"plugin": {
"maplibre-gl-annotations": "Аннотации",
@@ -4163,33 +4165,97 @@
"count_other": "Записано {{count}} запуска",
"toolUnavailable": "Инструмент «{{toolId}}» больше недоступен"
},
- "modelBuilder": {
- "moveStepUp": "Переместить шаг вверх",
- "moveStepDown": "Переместить шаг вниз",
- "removeStep": "Удалить шаг",
- "title": "Пакетная обработка и модели",
- "description": "Запустите векторный инструмент для множества слоёв или объедините инструменты в переиспользуемую модель, сохраняемую вместе с проектом.",
- "tabBatch": "Пакет",
- "tabModels": "Модели",
- "outputPlaceholder": "Здесь появится вывод.",
+ "batchTools": {
+ "title": "Пакетные инструменты",
+ "description": "Запустить один векторный инструмент сразу для многих слоёв.",
+ "runBatch": "Запустить пакет",
"tool": "Инструмент",
"sharedParameters": "Общие параметры",
"noExtraParameters": "У этого инструмента нет дополнительных параметров.",
"inputLayers": "Входные слои",
- "selectAll": "Выбрать все",
"clearSelection": "Очистить",
+ "selectAll": "Выбрать все",
"noCompatibleLayers": "Нет совместимых слоёв GeoJSON.",
- "newModel": "Новая модель",
- "noSavedModels": "Сохранённых моделей пока нет.",
+ "outputPlaceholder": "Здесь появится вывод."
+ },
+ "modelBuilder": {
+ "title": "Конструктор моделей",
+ "description": "Перетащите инструменты на холст и соедините их в модель обработки.",
+ "modelName": "Имя модели",
+ "modelNamePlaceholder": "Модель без названия",
"untitledModel": "Модель без названия",
- "modelName": "Название модели",
- "emptyPipelineHint": "Добавьте шаг, чтобы начать построение конвейера. Первый шаг читает входной слой; каждый следующий шаг получает вывод предыдущего.",
- "addStep": "Добавить шаг",
- "runModel": "Запустить модель",
+ "newModel": "Создать",
+ "discardChanges": "Отменить несохранённые изменения текущей модели?",
+ "arrange": "Упорядочить",
+ "arrangeHint": "Расставить узлы вдоль потока обработки",
+ "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": "Сохранённые модели",
+ "loadModelPlaceholder": "Загрузить сохранённую модель...",
"deleteModel": "Удалить",
- "inputPreviousStep": "Вход: ← вывод предыдущего шага",
- "unknownTool": "Неизвестный инструмент «{{id}}»",
- "noParameters": "Нет параметров."
+ "deletedLog": "Модель удалена из проекта.",
+ "searchTools": "Поиск инструментов",
+ "loadingTools": "Загрузка инструментов...",
+ "noToolsMatch": "Нет инструментов, соответствующих запросу.",
+ "addInputNode": "+ Вход",
+ "addOutputNode": "+ Выход",
+ "canvasEmpty": "Перетащите инструмент из палитры, чтобы начать.",
+ "inputNode": "Вход",
+ "outputNode": "Выход",
+ "inputPort": "Вход: {{port}}",
+ "outputPort": "Выход: {{port}}",
+ "removeConnection": "Удалить связь",
+ "removeNode": "Удалить узел",
+ "resizePanel": "Изменить размер панели",
+ "minimizePanel": "Свернуть панель",
+ "restorePanel": "Развернуть панель",
+ "resizePalette": "Изменить размер палитры инструментов",
+ "resizeInspector": "Изменить размер панели свойств",
+ "resizeLog": "Изменить размер журнала сообщений",
+ "selectNodeHint": "Выберите узел, чтобы изменить его настройки.",
+ "sourceLayer": "Исходный слой",
+ "chooseLayer": "Выберите слой...",
+ "resultName": "Имя результата",
+ "resultNamePlaceholder": "Вывод модели",
+ "noParameters": "Нет параметров.",
+ "keepResultHint": "Сохраните промежуточный результат, добавив для него выход.",
+ "keepResultSingle": "Сохранить этот результат",
+ "resultKeptSingle": "Этот результат сохраняется",
+ "keepResult": "Сохранить «{{port}}»",
+ "resultKept": "«{{port}}» сохраняется",
+ "outputPlaceholder": "Здесь появятся сообщения.",
+ "connectCycle": "Эта связь создаст цикл.",
+ "connectSameNode": "Узел не может быть связан сам с собой.",
+ "fixIssuesFirst": "Устраните указанные проблемы перед запуском.",
+ "runFailed": "Не удалось выполнить",
+ "runFinished": "Выполнение завершено — добавлено выходов: {{outputs}}.",
+ "savedLog": "Модель сохранена в проекте.",
+ "exportedLog": "{{name}} экспортирован",
+ "importedLog": "Импортирована модель с узлами: {{nodes}}.",
+ "importFailed": "Не удалось импортировать",
+ "importInvalid": "Этот файл не содержит графа модели.",
+ "importUnsupported": "Этот файл не является моделью GeoLibre.",
+ "rasterOutputUnsupported": "«{{name}}» — растровый результат, который эта сборка не может добавить на карту.",
+ "portNeedsVector": "«{{port}}» требует векторных данных, но поступил растр.",
+ "toolNoOutput": "«{{tool}}» не выдал результата.",
+ "toolNoUsableOutput": "«{{tool}}» не выдал пригодного результата."
},
"parameterField": {
"selectLayer": "Выберите слой...",
@@ -5645,7 +5711,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 eb73849c3d..7f8907a9ec 100644
--- a/apps/geolibre-desktop/src/i18n/locales/th.json
+++ b/apps/geolibre-desktop/src/i18n/locales/th.json
@@ -2171,7 +2171,8 @@
"dashboard": "แดชบอร์ด",
"assistant": "ผู้ช่วย AI",
"geocode": "แปลงที่อยู่เป็นพิกัด",
- "modelBuilder": "งานแบบชุดและโมเดล",
+ "batchTools": "เครื่องมือแบบกลุ่ม",
+ "modelBuilder": "ตัวสร้างแบบจำลอง",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "การแบ่งส่วนภาพด้วย AI",
@@ -2499,7 +2500,7 @@
"duckdbLayer": "เลเยอร์ DuckDB",
"whitebox": "Whitebox",
"geocode": "แปลงที่อยู่เป็นพิกัด",
- "modelBuilder": "งานแบบชุดและโมเดล",
+ "modelBuilder": "ตัวสร้างแบบจำลอง",
"processingHistory": "ประวัติ",
"conversion": "การแปลงรูปแบบ",
"vector": "เวกเตอร์",
@@ -2663,7 +2664,8 @@
"projectName": "ชื่อโปรเจกต์",
"storymapEllipsis": "แผนที่เล่าเรื่อง...",
"pointerElevationNoticeTitle": "ระดับความสูงใช้บริการสาธารณะ",
- "pointerElevationNoticeDesc": "เมื่อเปิดภูมิประเทศ 3 มิติ ระดับความสูงจะคำนวณจากแผนที่เองโดยไม่ส่งข้อมูลใด ๆ หากไม่มีภูมิประเทศ 3 มิติ จะสอบถาม Open-Meteo API สาธารณะ และพิกัดใต้ตัวชี้จะออกจากอุปกรณ์ของคุณ"
+ "pointerElevationNoticeDesc": "เมื่อเปิดภูมิประเทศ 3 มิติ ระดับความสูงจะคำนวณจากแผนที่เองโดยไม่ส่งข้อมูลใด ๆ หากไม่มีภูมิประเทศ 3 มิติ จะสอบถาม Open-Meteo API สาธารณะ และพิกัดใต้ตัวชี้จะออกจากอุปกรณ์ของคุณ",
+ "batchTools": "เครื่องมือแบบกลุ่ม"
},
"plugin": {
"maplibre-gl-annotations": "คำอธิบายประกอบ",
@@ -3962,33 +3964,97 @@
"count_other": "บันทึกการประมวลผลไว้ {{count}} ครั้ง",
"toolUnavailable": "ไม่มีเครื่องมือ \"{{toolId}}\" ให้ใช้งานแล้ว"
},
- "modelBuilder": {
- "moveStepUp": "เลื่อนขั้นตอนขึ้น",
- "moveStepDown": "เลื่อนขั้นตอนลง",
- "removeStep": "นำขั้นตอนออก",
- "title": "งานแบบชุดและโมเดล",
- "description": "เรียกใช้เครื่องมือเวกเตอร์กับหลายเลเยอร์พร้อมกัน หรือเชื่อมโยงเครื่องมือหลายตัวเป็นโมเดลที่นำกลับมาใช้ใหม่ได้และบันทึกไว้กับโปรเจกต์ของคุณ",
- "tabBatch": "งานแบบชุด",
- "tabModels": "โมเดล",
- "outputPlaceholder": "ผลลัพธ์จะแสดงที่นี่",
+ "batchTools": {
+ "title": "เครื่องมือแบบกลุ่ม",
+ "description": "เรียกใช้เครื่องมือเวกเตอร์เดียวกับหลายชั้นข้อมูลพร้อมกัน",
+ "runBatch": "เรียกใช้แบบกลุ่ม",
"tool": "เครื่องมือ",
"sharedParameters": "พารามิเตอร์ที่ใช้ร่วมกัน",
"noExtraParameters": "เครื่องมือนี้ไม่มีพารามิเตอร์เพิ่มเติม",
"inputLayers": "เลเยอร์นำเข้า",
- "selectAll": "เลือกทั้งหมด",
"clearSelection": "ล้าง",
+ "selectAll": "เลือกทั้งหมด",
"noCompatibleLayers": "ไม่มีเลเยอร์ GeoJSON ที่ใช้งานร่วมกันได้",
- "newModel": "โมเดลใหม่",
- "noSavedModels": "ยังไม่มีโมเดลที่บันทึกไว้",
- "untitledModel": "โมเดลไม่มีชื่อ",
- "modelName": "ชื่อโมเดล",
- "emptyPipelineHint": "เพิ่มขั้นตอนเพื่อเริ่มสร้างไปป์ไลน์ ขั้นตอนแรกจะอ่านเลเยอร์นำเข้า ส่วนขั้นตอนถัดไปจะรับผลลัพธ์จากขั้นตอนก่อนหน้า",
- "addStep": "เพิ่มขั้นตอน",
- "runModel": "เรียกใช้โมเดล",
+ "outputPlaceholder": "ผลลัพธ์จะแสดงที่นี่"
+ },
+ "modelBuilder": {
+ "title": "ตัวสร้างแบบจำลอง",
+ "description": "ลากเครื่องมือมาวางบนพื้นที่ทำงานแล้วเชื่อมต่อกันเป็นแบบจำลองการประมวลผล",
+ "modelName": "ชื่อแบบจำลอง",
+ "modelNamePlaceholder": "แบบจำลองไม่มีชื่อ",
+ "untitledModel": "แบบจำลองไม่มีชื่อ",
+ "newModel": "ใหม่",
+ "discardChanges": "ทิ้งการเปลี่ยนแปลงที่ยังไม่ได้บันทึกของแบบจำลองปัจจุบันหรือไม่?",
+ "arrange": "จัดเรียง",
+ "arrangeHint": "จัดเรียงโหนดตามลำดับการไหลของงาน",
+ "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": "แบบจำลองที่บันทึกไว้",
+ "loadModelPlaceholder": "โหลดแบบจำลองที่บันทึกไว้...",
"deleteModel": "ลบ",
- "inputPreviousStep": "ข้อมูลนำเข้า: ← ผลลัพธ์จากขั้นตอนก่อนหน้า",
- "unknownTool": "ไม่รู้จักเครื่องมือ \"{{id}}\"",
- "noParameters": "ไม่มีพารามิเตอร์"
+ "deletedLog": "ลบแบบจำลองออกจากโครงการแล้ว",
+ "searchTools": "ค้นหาเครื่องมือ",
+ "loadingTools": "กำลังโหลดเครื่องมือ...",
+ "noToolsMatch": "ไม่มีเครื่องมือที่ตรงกับการค้นหาของคุณ",
+ "addInputNode": "+ อินพุต",
+ "addOutputNode": "+ เอาต์พุต",
+ "canvasEmpty": "ลากเครื่องมือจากแผงเครื่องมือเพื่อเริ่มสร้าง",
+ "inputNode": "อินพุต",
+ "outputNode": "เอาต์พุต",
+ "inputPort": "อินพุต: {{port}}",
+ "outputPort": "เอาต์พุต: {{port}}",
+ "removeConnection": "ลบการเชื่อมต่อ",
+ "removeNode": "ลบโหนด",
+ "resizePanel": "ปรับขนาดแผง",
+ "minimizePanel": "ย่อแผง",
+ "restorePanel": "คืนขนาดแผง",
+ "resizePalette": "ปรับขนาดแผงเครื่องมือ",
+ "resizeInspector": "ปรับขนาดแผงคุณสมบัติ",
+ "resizeLog": "ปรับขนาดบันทึกข้อความ",
+ "selectNodeHint": "เลือกโหนดเพื่อแก้ไขการตั้งค่า",
+ "sourceLayer": "ชั้นข้อมูลต้นทาง",
+ "chooseLayer": "เลือกชั้นข้อมูล...",
+ "resultName": "ชื่อผลลัพธ์",
+ "resultNamePlaceholder": "เอาต์พุตของแบบจำลอง",
+ "noParameters": "ไม่มีพารามิเตอร์",
+ "keepResultHint": "เก็บผลลัพธ์ระหว่างทางได้ด้วยการเพิ่มเอาต์พุตให้กับมัน",
+ "keepResultSingle": "เก็บผลลัพธ์นี้",
+ "resultKeptSingle": "เก็บผลลัพธ์นี้ไว้แล้ว",
+ "keepResult": "เก็บ \"{{port}}\"",
+ "resultKept": "เก็บ \"{{port}}\" ไว้แล้ว",
+ "outputPlaceholder": "ข้อความจะปรากฏที่นี่",
+ "connectCycle": "การเชื่อมต่อนั้นจะทำให้เกิดวงวน",
+ "connectSameNode": "โหนดไม่สามารถเชื่อมต่อกับตัวเองได้",
+ "fixIssuesFirst": "แก้ไขปัญหาที่รายงานก่อนเรียกใช้",
+ "runFailed": "เรียกใช้ไม่สำเร็จ",
+ "runFinished": "เรียกใช้เสร็จสิ้น — เพิ่มเอาต์พุตแล้ว {{outputs}} รายการ",
+ "savedLog": "บันทึกแบบจำลองลงในโครงการแล้ว",
+ "exportedLog": "ส่งออก {{name}} แล้ว",
+ "importedLog": "นำเข้าแบบจำลองที่มี {{nodes}} โหนดแล้ว",
+ "importFailed": "นำเข้าไม่สำเร็จ",
+ "importInvalid": "ไฟล์นั้นไม่มีกราฟของแบบจำลอง",
+ "importUnsupported": "ไฟล์นั้นไม่ใช่แบบจำลองของ GeoLibre",
+ "rasterOutputUnsupported": "\"{{name}}\" เป็นผลลัพธ์แรสเตอร์ซึ่งรุ่นนี้ไม่สามารถเพิ่มลงในแผนที่ได้",
+ "portNeedsVector": "\"{{port}}\" ต้องการข้อมูลเวกเตอร์ แต่ได้รับแรสเตอร์",
+ "toolNoOutput": "\"{{tool}}\" ไม่ได้สร้างผลลัพธ์",
+ "toolNoUsableOutput": "\"{{tool}}\" ไม่ได้สร้างผลลัพธ์ที่ใช้งานได้"
},
"parameterField": {
"selectLayer": "เลือกเลเยอร์...",
@@ -5405,7 +5471,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 8b06977387..79f8ee0fdb 100644
--- a/apps/geolibre-desktop/src/i18n/locales/tr.json
+++ b/apps/geolibre-desktop/src/i18n/locales/tr.json
@@ -2216,7 +2216,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",
@@ -2544,7 +2545,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",
@@ -2711,7 +2712,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",
@@ -4029,33 +4031,97 @@
"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.",
+ "runBatch": "Toplu çalıştır",
"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",
+ "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",
+ "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",
+ "loadModelPlaceholder": "Kayıtlı bir model yükle...",
"deleteModel": "Sil",
- "inputPreviousStep": "Girdi: ← önceki adımın çıktısı",
- "unknownTool": "Bilinmeyen araç \"{{id}}\"",
- "noParameters": "Parametre yok."
+ "deletedLog": "Model projeden silindi.",
+ "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",
+ "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",
+ "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.",
+ "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.",
+ "importUnsupported": "Bu dosya bir GeoLibre modeli değil.",
+ "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...",
@@ -5485,7 +5551,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 4b56c46509..cc606f9547 100644
--- a/apps/geolibre-desktop/src/i18n/locales/vi.json
+++ b/apps/geolibre-desktop/src/i18n/locales/vi.json
@@ -2168,7 +2168,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",
@@ -2506,7 +2507,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",
@@ -2660,7 +2661,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ụ",
@@ -4048,33 +4050,97 @@
"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.",
+ "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.",
"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",
+ "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",
+ "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",
+ "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.",
+ "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",
+ "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",
+ "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ó.",
+ "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.",
+ "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 đồ.",
+ "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",
@@ -5379,7 +5445,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 4e589de175..42cd776c22 100644
--- a/apps/geolibre-desktop/src/i18n/locales/zh.json
+++ b/apps/geolibre-desktop/src/i18n/locales/zh.json
@@ -2171,7 +2171,8 @@
"dashboard": "仪表盘",
"assistant": "AI 助手",
"geocode": "地理编码地址",
- "modelBuilder": "批处理与模型",
+ "batchTools": "批处理工具",
+ "modelBuilder": "模型构建器",
"planetaryComputer": "Planetary Computer",
"earthEngine": "Earth Engine",
"segmentation": "AI 分割",
@@ -2499,7 +2500,7 @@
"duckdbLayer": "DuckDB 图层",
"whitebox": "Whitebox",
"geocode": "地理编码地址",
- "modelBuilder": "批处理与模型",
+ "modelBuilder": "模型构建器",
"processingHistory": "历史记录",
"conversion": "转换",
"vector": "矢量",
@@ -2663,7 +2664,8 @@
"projectName": "项目名称",
"storymapEllipsis": "故事地图...",
"pointerElevationNoticeTitle": "高程会使用公共服务",
- "pointerElevationNoticeDesc": "启用三维地形时,高程直接由地图计算,不会发送任何数据。未启用时将查询公共 Open-Meteo API,指针下方的坐标会离开您的设备。"
+ "pointerElevationNoticeDesc": "启用三维地形时,高程直接由地图计算,不会发送任何数据。未启用时将查询公共 Open-Meteo API,指针下方的坐标会离开您的设备。",
+ "batchTools": "批处理工具"
},
"plugin": {
"maplibre-gl-annotations": "注释",
@@ -3962,33 +3964,97 @@
"count_other": "已记录 {{count}} 次运行",
"toolUnavailable": "工具“{{toolId}}”已不可用"
},
- "modelBuilder": {
- "moveStepUp": "上移步骤",
- "moveStepDown": "下移步骤",
- "removeStep": "移除步骤",
- "title": "批处理与模型",
- "description": "对多个图层运行同一个矢量工具,或将多个工具串联成可复用的模型并随项目保存。",
- "tabBatch": "批处理",
- "tabModels": "模型",
- "outputPlaceholder": "输出将显示在此处。",
+ "batchTools": {
+ "title": "批处理工具",
+ "description": "对多个图层一次性运行同一个矢量工具。",
+ "runBatch": "运行批处理",
"tool": "工具",
"sharedParameters": "共享参数",
"noExtraParameters": "此工具没有额外参数。",
"inputLayers": "输入图层",
- "selectAll": "全选",
"clearSelection": "清除",
+ "selectAll": "全选",
"noCompatibleLayers": "没有兼容的 GeoJSON 图层。",
- "newModel": "新建模型",
- "noSavedModels": "尚无已保存的模型。",
- "untitledModel": "未命名模型",
+ "outputPlaceholder": "输出将显示在此处。"
+ },
+ "modelBuilder": {
+ "title": "模型构建器",
+ "description": "将工具拖到画布上,并将它们连接成一个处理模型。",
"modelName": "模型名称",
- "emptyPipelineHint": "添加一个步骤以开始构建流程。第一步读取输入图层;后续每一步接收上一步的输出。",
- "addStep": "添加步骤",
- "runModel": "运行模型",
+ "modelNamePlaceholder": "未命名模型",
+ "untitledModel": "未命名模型",
+ "newModel": "新建",
+ "discardChanges": "是否放弃当前模型的未保存更改?",
+ "arrange": "排列",
+ "arrangeHint": "沿处理流程排列节点",
+ "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": "已保存的模型",
+ "loadModelPlaceholder": "加载已保存的模型…",
"deleteModel": "删除",
- "inputPreviousStep": "输入:← 上一步的输出",
- "unknownTool": "未知工具“{{id}}”",
- "noParameters": "没有参数。"
+ "deletedLog": "模型已从项目中删除。",
+ "searchTools": "搜索工具",
+ "loadingTools": "正在加载工具…",
+ "noToolsMatch": "没有与搜索匹配的工具。",
+ "addInputNode": "+ 输入",
+ "addOutputNode": "+ 输出",
+ "canvasEmpty": "从工具面板拖入一个工具即可开始搭建。",
+ "inputNode": "输入",
+ "outputNode": "输出",
+ "inputPort": "输入:{{port}}",
+ "outputPort": "输出:{{port}}",
+ "removeConnection": "删除连接",
+ "removeNode": "删除节点",
+ "resizePanel": "调整面板大小",
+ "minimizePanel": "最小化面板",
+ "restorePanel": "还原面板",
+ "resizePalette": "调整工具面板大小",
+ "resizeInspector": "调整属性面板大小",
+ "resizeLog": "调整消息日志大小",
+ "selectNodeHint": "选择一个节点以编辑其设置。",
+ "sourceLayer": "源图层",
+ "chooseLayer": "选择图层…",
+ "resultName": "结果名称",
+ "resultNamePlaceholder": "模型输出",
+ "noParameters": "无参数。",
+ "keepResultHint": "为中间结果添加一个输出即可保留它。",
+ "keepResultSingle": "保留此结果",
+ "resultKeptSingle": "此结果已保留",
+ "keepResult": "保留“{{port}}”",
+ "resultKept": "“{{port}}”已保留",
+ "outputPlaceholder": "消息将显示在此处。",
+ "connectCycle": "该连接会形成环路。",
+ "connectSameNode": "节点不能连接到自身。",
+ "fixIssuesFirst": "请先修复所报告的问题,然后再运行。",
+ "runFailed": "运行失败",
+ "runFinished": "运行完成 — 已添加 {{outputs}} 个输出。",
+ "savedLog": "模型已保存到项目中。",
+ "exportedLog": "已导出 {{name}}",
+ "importedLog": "已导入包含 {{nodes}} 个节点的模型。",
+ "importFailed": "导入失败",
+ "importInvalid": "该文件不包含模型图。",
+ "importUnsupported": "该文件不是 GeoLibre 模型。",
+ "rasterOutputUnsupported": "“{{name}}”是栅格结果,此版本无法将其添加到地图。",
+ "portNeedsVector": "“{{port}}”需要矢量数据,但收到的是栅格。",
+ "toolNoOutput": "“{{tool}}”没有产生输出。",
+ "toolNoUsableOutput": "“{{tool}}”没有产生可用的输出。"
},
"parameterField": {
"selectLayer": "选择一个图层...",
@@ -5405,7 +5471,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..32b6f6fcea
--- /dev/null
+++ b/apps/geolibre-desktop/src/lib/model-graph-edit.ts
@@ -0,0 +1,545 @@
+import type {
+ ModelGraphEdge,
+ ModelGraphNode,
+ ModelGraphNodeKind,
+ ModelToolProvider,
+ ProcessingModelGraph,
+} from "@geolibre/core";
+import { OUTPUT_NODE_PORT, 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, following the pointer exactly. */
+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,
+ ),
+ };
+}
+
+/**
+ * Settle a just-dragged node so it does not sit on top of another card.
+ *
+ * Cards are painted in array order with no z-index, so a node dropped over
+ * another swallows the hit-test for whichever ports end up underneath — and the
+ * only way out would be to drag the invisible card blind. Dropping therefore
+ * nudges the node to the nearest clear footprint (never moving the others) and
+ * re-appends it so it paints last and its own ports stay reachable.
+ *
+ * @param graph The graph after the drag.
+ * @param nodeId The node that was dragged.
+ * @returns The graph with that node settled and moved to the end of the paint order.
+ */
+export function settleNode(graph: ProcessingModelGraph, nodeId: string): ProcessingModelGraph {
+ const dragged = graph.nodes.find((node) => node.id === nodeId);
+ if (!dragged) return graph;
+ const others = graph.nodes.filter((node) => node.id !== nodeId);
+ const free = findFreePosition({ ...graph, nodes: others }, { x: dragged.x, y: dragged.y });
+ return {
+ ...graph,
+ nodes: [...others, { ...dragged, x: free.x, y: free.y }],
+ };
+}
+
+/** 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.
+ *
+ * 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,
+ options: LayoutOptions = {},
+): ProcessingModelGraph {
+ const placed = graph.nodes.some((node) => node.x !== 0 || node.y !== 0);
+ if (placed) return 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 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,
+ options: LayoutOptions = {},
+): ProcessingModelGraph {
+ if (graph.nodes.length === 0) return graph;
+ 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();
+ for (const edge of graph.edges) {
+ const list = incoming.get(edge.to) ?? [];
+ list.push(edge.from);
+ incoming.set(edge.to, list);
+ }
+ // 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);
+
+ // 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 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,
+ };
+ }),
+ };
+}
+
+/**
+ * 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);
+}
+
+/**
+ * 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.
+ * @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.
+ */
+export function addOutputForPort(
+ graph: ProcessingModelGraph,
+ 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;
+ // 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 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(
+ named,
+ { 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: 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,
+ 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/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..1ad64b67af
--- /dev/null
+++ b/apps/geolibre-desktop/src/lib/model-tool-catalog.ts
@@ -0,0 +1,215 @@
+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,
+ // The WASM runner walks this manifest to build its CLI arguments.
+ native: tool,
+ };
+}
+
+/**
+ * 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 5a00b0a350..fa99d92a20 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,
@@ -668,11 +673,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 c623848bde..c3b0a64b04 100644
--- a/packages/core/src/store.ts
+++ b/packages/core/src/store.ts
@@ -338,6 +338,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;
@@ -460,6 +463,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. */
@@ -1058,6 +1062,7 @@ export const useAppStore = create()(
storymapPresenting: false,
storymapReturnToEditor: false,
storymapComposingId: null,
+ batchToolsOpen: false,
modelBuilderOpen: false,
styleManagerOpen: false,
processingHistoryOpen: false,
@@ -1394,6 +1399,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 b490bcb75e..92f4e5560f 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1585,14 +1585,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..501adbf4d8
--- /dev/null
+++ b/packages/processing/src/model-graph.ts
@@ -0,0 +1,527 @@
+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}.
+ *
+ * `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
+ * 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 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. */
+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"
+ | "duplicate-node"
+ | "dangling-edge";
+ nodeId?: string;
+ edgeId?: string;
+ /**
+ * 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;
+}
+
+/** 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[] = [];
+ // Index cursor rather than shift(): shift() is O(remaining) per call, which
+ // makes a wide fan-out quadratic, and this runs on every graph edit.
+ for (let head = 0; head < queue.length; head++) {
+ const node = queue[head];
+ 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: INPUT_NODE_PORT, kind: "any" }] };
+ }
+ if (node.kind === "output") {
+ return {
+ inputs: [{ id: OUTPUT_NODE_PORT, label: OUTPUT_NODE_PORT, 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]));
+ // Every lookup here keys on node id, so a duplicate silently collapses to
+ // "last write wins" while both entries stay in `nodes` — the run would then
+ // execute only one of them, with no error anywhere.
+ const idCounts = new Map();
+ for (const node of graph.nodes) idCounts.set(node.id, (idCounts.get(node.id) ?? 0) + 1);
+ for (const [id, count] of idCounts) {
+ if (count > 1) {
+ issues.push({
+ code: "duplicate-node",
+ nodeId: id,
+ message: `Two or more nodes share the id "${id}".`,
+ });
+ }
+ }
+ 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,
+ detail: node.toolId ?? "",
+ 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) {
+ // Skipping these silently makes a stray edge's wiring simply vanish, and
+ // graphToLinearSteps still counts them when deciding whether a node has
+ // exactly one predecessor.
+ issues.push({
+ code: "dangling-edge",
+ edgeId: edge.id,
+ message: "A connection points at a node that no longer exists.",
+ });
+ 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,
+ detail: toPort.label,
+ 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,
+ detail: port.label,
+ 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.
+ * 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;
+ 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 ? await 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 = await 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) {
+ // executeTool wraps arbitrary WASM/sidecar calls, which can reject with a
+ // non-Error; reading `.message` off that would throw inside this handler
+ // and escape as an unhandled rejection instead of the documented result.
+ const message = err instanceof Error ? err.message : String(err);
+ 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 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.
+ */
+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 nodeIds = new Set(graph.nodes.map((node) => node.id));
+ const incoming = new Map();
+ const outgoing = new Map();
+ for (const edge of graph.edges) {
+ // Count only edges between nodes that exist: a dangling edge would
+ // otherwise make a node look like it has the one predecessor this
+ // projection requires, defeating the "refuse rather than truncate" rule.
+ if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) continue;
+ 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;
+ 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 && 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,
+ ...(inputEdge && inputEdge.toPort !== "layer" ? { inputParam: inputEdge.toPort } : {}),
+ });
+ }
+ return steps;
+}
diff --git a/tests/core-project.test.ts b/tests/core-project.test.ts
index f35672cf91..fa97520866 100644
--- a/tests/core-project.test.ts
+++ b/tests/core-project.test.ts
@@ -7,6 +7,7 @@ import {
createDefaultPrintLayout,
createEmptyProject,
createSampleStoryMap,
+ normalizeModelGraph,
parseProject,
parseStoryMapCsv,
parseStoryMapJson,
@@ -1474,6 +1475,66 @@ describe("primary mapView normalization", () => {
});
});
+describe("normalizeModelGraph", () => {
+ it("supplies an empty edge list when the key is missing entirely", () => {
+ // A hand-edited file without `edges` used to reach the canvas as
+ // `edges: undefined`, and the renderer's `graph.edges.map(...)` then threw
+ // out of render — past the importer's try/catch — into the error boundary,
+ // instead of showing the friendly "not a model" message.
+ const graph = normalizeModelGraph({
+ nodes: [{ id: "a", kind: "input", x: 10, y: 20, layerId: "roads" }],
+ });
+ assert.deepEqual(graph?.edges, []);
+ assert.equal(graph?.nodes.length, 1);
+ });
+
+ it("drops edges that do not connect two surviving nodes", () => {
+ const graph = normalizeModelGraph({
+ nodes: [
+ { id: "a", kind: "input", x: 0, y: 0 },
+ { id: "b", kind: "output", x: 0, y: 0 },
+ { id: "", kind: "tool", x: 0, y: 0 },
+ ],
+ edges: [
+ { id: "e1", from: "a", fromPort: "out", to: "b", toPort: "in" },
+ { id: "e2", from: "a", fromPort: "out", to: "ghost", toPort: "in" },
+ { id: "e3", from: "a", fromPort: "out", to: "a", toPort: "in" },
+ ],
+ });
+ assert.deepEqual(
+ graph?.edges.map((edge) => edge.id),
+ ["e1"],
+ );
+ });
+
+ it("rejects a node with an unknown kind rather than passing it to the runner", () => {
+ const graph = normalizeModelGraph({
+ nodes: [
+ { id: "a", kind: "wat", x: 0, y: 0 },
+ { id: "b", kind: "output", x: 0, y: 0 },
+ ],
+ edges: [],
+ });
+ assert.deepEqual(
+ graph?.nodes.map((node) => node.id),
+ ["b"],
+ );
+ });
+
+ it("returns null for a value carrying no usable nodes", () => {
+ assert.equal(normalizeModelGraph(null), null);
+ assert.equal(normalizeModelGraph({ nodes: [] }), null);
+ assert.equal(normalizeModelGraph({ nodes: "nope" }), null);
+ });
+
+ it("coerces a non-finite coordinate instead of laying the node out at NaN", () => {
+ const graph = normalizeModelGraph({
+ nodes: [{ id: "a", kind: "input", x: "left", y: Number.NaN }],
+ });
+ assert.deepEqual([graph?.nodes[0].x, graph?.nodes[0].y], [0, 0]);
+ });
+});
+
describe("print layout persistence", () => {
beforeEach(() => {
useAppStore.getState().newProject({ name: "Layout Project" });
diff --git a/tests/model-graph-edit.test.ts b/tests/model-graph-edit.test.ts
new file mode 100644
index 0000000000..84eeb25390
--- /dev/null
+++ b/tests/model-graph-edit.test.ts
@@ -0,0 +1,616 @@
+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,
+ addOutputForPort,
+ addToolNode,
+ autoLayout,
+ connectNodes,
+ createsCycle,
+ emptyModelGraph,
+ graphsEqual,
+ layoutGraph,
+ moveNode,
+ portFeedsOutput,
+ removeEdge,
+ removeNode,
+ setNodeField,
+ setNodeParameter,
+ settleNode,
+ uniqueOutputName,
+ NODE_HEIGHT,
+ NODE_WIDTH,
+} 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("settling a dragged node", () => {
+ const stacked = (): ProcessingModelGraph => ({
+ nodes: [
+ { id: "a", kind: "input", x: 0, y: 0, layerId: "roads" },
+ { id: "b", kind: "tool", x: 400, y: 400, provider: "vector", toolId: "buffer" },
+ ],
+ edges: [],
+ });
+
+ it("moves a card off one it was dropped on top of", () => {
+ // Dropped squarely onto `a`: leaving it there would make one card's ports
+ // unclickable, with no way to separate them except blind dragging.
+ let graph = moveNode(stacked(), "b", { x: 0, y: 0 });
+ graph = settleNode(graph, "b");
+ const a = graph.nodes.find((n) => n.id === "a")!;
+ const b = graph.nodes.find((n) => n.id === "b")!;
+ const overlaps = Math.abs(a.x - b.x) < NODE_WIDTH && Math.abs(a.y - b.y) < NODE_HEIGHT;
+ assert.equal(overlaps, false);
+ });
+
+ it("leaves a card dropped in clear space exactly where the pointer left it", () => {
+ let graph = moveNode(stacked(), "b", { x: 700, y: 500 });
+ graph = settleNode(graph, "b");
+ const b = graph.nodes.find((n) => n.id === "b")!;
+ assert.deepEqual([b.x, b.y], [700, 500]);
+ });
+
+ it("repaints the dragged card last so its own ports stay on top", () => {
+ const graph = settleNode(stacked(), "a");
+ assert.equal(graph.nodes[graph.nodes.length - 1].id, "a");
+ });
+
+ it("never displaces the cards it was dropped near", () => {
+ let graph = moveNode(stacked(), "b", { x: 0, y: 0 });
+ graph = settleNode(graph, "b");
+ const a = graph.nodes.find((n) => n.id === "a")!;
+ assert.deepEqual([a.x, a.y], [0, 0]);
+ });
+});
+
+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("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: [
+ { 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);
+ });
+
+ 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);
+ });
+
+ /** 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", () => {
+ 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);
+ });
+});
+
+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);
+ });
+
+ 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");
+ });
+});
diff --git a/tests/model-graph.test.ts b/tests/model-graph.test.ts
new file mode 100644
index 0000000000..37403785fa
--- /dev/null
+++ b/tests/model-graph.test.ts
@@ -0,0 +1,567 @@
+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("reports two nodes sharing an id, which lookups would silently collapse", () => {
+ const graph = chainGraph();
+ graph.nodes.push({ ...graph.nodes[1], x: 50 });
+ const dup = validateModelGraph(graph, resolve).filter(
+ (issue) => issue.code === "duplicate-node",
+ );
+ assert.equal(dup.length, 1);
+ assert.equal(dup[0].nodeId, "t1");
+ });
+
+ it("reports an edge pointing at a node that no longer exists", () => {
+ const graph = chainGraph();
+ graph.edges.push({ id: "e9", from: "ghost", fromPort: "out", to: "t1", toPort: "layer" });
+ const codes = validateModelGraph(graph, resolve).map((issue) => issue.code);
+ 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");
+ 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("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: [
+ {
+ 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("reports a non-Error rejection instead of throwing inside its own handler", async () => {
+ const { options } = baseOptions();
+ const result = await runModelGraph(chainGraph(), {
+ ...options,
+ executeTool: async () => {
+ // A WASM/sidecar call can reject with something that is not an Error.
+ throw "plain string failure";
+ },
+ });
+ assert.equal(result.error?.nodeId, "t1");
+ assert.match(result.error?.message ?? "", /plain string failure/);
+ });
+
+ 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"],
+ );
+ // 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", () => {
+ 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("ignores a dangling edge when counting a node's predecessors", () => {
+ // Without this the stray edge makes `t1` look like it has two predecessors
+ // and the projection bails, or worse counts it as the one real predecessor.
+ const graph = chainGraph();
+ graph.edges.push({ id: "e9", from: "ghost", fromPort: "out", to: "t1", toPort: "layer" });
+ const steps = graphToLinearSteps(graph);
+ assert.deepEqual(
+ steps.map((step) => step.toolId),
+ ["buffer"],
+ );
+ });
+
+ 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..88514e96f3
--- /dev/null
+++ b/tests/model-tool-catalog.test.ts
@@ -0,0 +1,199 @@
+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("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",
+ 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);
+ });
+});