Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 157 additions & 8 deletions apps/geolibre-desktop/src/components/processing/ModelBuilderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type RunnerHost,
} from "@geolibre/processing";
import { createDuckDbCapability } from "../../lib/duckdb-processing";
import { modelToPipeline, pipelineToModel } from "../../lib/processing-pipeline";
import {
Button,
Dialog,
Expand All @@ -35,12 +36,14 @@ import { ParameterField } from "./ParameterField";
import {
ArrowDown,
ArrowUp,
Download,
Layers,
Loader2,
Play,
Plus,
Save,
Trash2,
Upload,
Workflow,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
Expand Down Expand Up @@ -176,7 +179,7 @@ export function ModelBuilderDialog({ mapControllerRef }: ModelBuilderDialogProps
if (!next) setOpen(false);
}}
>
<DialogContent className="max-w-3xl">
<DialogContent className="max-w-5xl">
Comment thread
giswqs marked this conversation as resolved.
Outdated
<DialogHeader>
<DialogTitle>{t("processing.modelBuilder.title")}</DialogTitle>
<DialogDescription>{t("processing.modelBuilder.description")}</DialogDescription>
Expand Down Expand Up @@ -499,13 +502,16 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement
const [addToolId, setAddToolId] = useState<string>(VECTOR_TOOLS[0].id);
const [log, setLog] = useState<string[]>([]);
const [running, setRunning] = useState(false);
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const importRef = useRef<HTMLInputElement>(null);

const appendLog = useCallback((message: string) => setLog((prev) => [...prev, message]), []);
const fieldsByLayer = useFieldsByLayer(layers, true);
const isSaved = models.some((m) => m.id === draft.id);

const newDraft = useCallback(() => {
setDraft({ id: createId(), name: "Untitled model", steps: [] });
setSelectedStepId(null);
setLog([]);
}, []);

Expand All @@ -516,25 +522,62 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement
name: model.name,
steps: model.steps.map((s) => ({ ...s, parameters: { ...s.parameters } })),
});
setSelectedStepId(model.steps[0]?.id ?? null);
setLog([]);
}, []);

const addStep = useCallback(() => {
const tool = getVectorTool(addToolId);
if (!tool) return;
const id = createId();
setDraft((prev) => ({
...prev,
steps: [...prev.steps, { id: createId(), toolId: tool.id, parameters: defaultParams(tool) }],
steps: [...prev.steps, { id, toolId: tool.id, parameters: defaultParams(tool) }],
}));
setSelectedStepId(id);
}, [addToolId]);

const removeStep = useCallback((stepId: string) => {
setDraft((prev) => ({
...prev,
steps: prev.steps.filter((s) => s.id !== stepId),
}));
setSelectedStepId((current) => (current === stepId ? null : current));
}, []);

const handleExport = useCallback(() => {
const json = JSON.stringify(modelToPipeline(draft), null, 2);
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
const anchor = document.createElement("a");
const slug = draft.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
anchor.href = url;
anchor.download = `${slug || "pipeline"}.pipeline.json`;
Comment thread
giswqs marked this conversation as resolved.
Outdated
anchor.click();
URL.revokeObjectURL(url);
appendLog(`Exported ${anchor.download}`);
}, [draft, appendLog]);
Comment thread
giswqs marked this conversation as resolved.
Outdated

const handleImport = useCallback(
async (file: File) => {
try {
const model = pipelineToModel(JSON.parse(await file.text()), createId);
for (const step of model.steps) {
if (!getVectorTool(step.toolId)) throw new Error(`Unknown vector tool "${step.toolId}"`);
}
setDraft(model);
setSelectedStepId(model.steps[0]?.id ?? null);
setLog([`Imported ${file.name}`]);
} catch (error) {
appendLog(`Error: ${(error as Error).message}`);
}
},
[appendLog],
);
Comment thread
giswqs marked this conversation as resolved.
Outdated

const moveStep = useCallback((stepId: string, dir: -1 | 1) => {
setDraft((prev) => {
const index = prev.steps.findIndex((s) => s.id === stepId);
Expand Down Expand Up @@ -668,6 +711,12 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement
/>
</div>

<WorkflowCanvas
steps={draft.steps}
selectedStepId={selectedStepId}
onSelect={setSelectedStepId}
/>

<ScrollArea className="h-56 rounded-md border p-2">
{draft.steps.length === 0 ? (
<p className="p-2 text-xs text-muted-foreground">
Expand All @@ -686,6 +735,8 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement
onParamChange={(paramId, value) => updateStepParam(step.id, paramId, value)}
onRemove={() => removeStep(step.id)}
onMove={(dir) => moveStep(step.id, dir)}
selected={step.id === selectedStepId}
onSelect={() => setSelectedStepId(step.id)}
/>
))}
</div>
Expand Down Expand Up @@ -714,6 +765,23 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement
<Button variant="outline" className="gap-2" onClick={handleSave}>
<Save className="h-4 w-4" /> {t("common.save")}
</Button>
<Button variant="outline" className="gap-2" onClick={() => importRef.current?.click()}>
<Upload className="h-4 w-4" /> {t("processing.modelBuilder.importPipeline")}
</Button>
<Button variant="outline" className="gap-2" onClick={handleExport}>
<Download className="h-4 w-4" /> {t("processing.modelBuilder.exportPipeline")}
</Button>
<input
ref={importRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleImport(file);
event.target.value = "";
}}
/>
<Button variant="outline" className="gap-2" onClick={handleDelete} disabled={!isSaved}>
<Trash2 className="h-4 w-4" /> {t("processing.modelBuilder.deleteModel")}
</Button>
Expand All @@ -725,6 +793,63 @@ function ModelPanel({ mapControllerRef }: ModelBuilderDialogProps): ReactElement
);
}

/** Compact node-and-edge canvas for the ordered graph executed by the model runner. */
function WorkflowCanvas({
steps,
selectedStepId,
onSelect,
}: {
steps: ProcessingModelStep[];
selectedStepId: string | null;
onSelect: (id: string) => void;
}): ReactElement {
const { t } = useTranslation();
return (
<div
className="min-h-28 overflow-x-auto rounded-md border bg-muted/20 p-4"
aria-label={t("processing.modelBuilder.canvas")}
>
{steps.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
{t("processing.modelBuilder.canvasEmpty")}
</p>
) : (
<div className="flex min-w-max items-center py-2">
{steps.map((step, index) => {
const tool = getVectorTool(step.toolId);
return (
<div key={step.id} className="flex items-center">
{index > 0 ? (
<div className="flex w-12 items-center" aria-hidden="true">
<div className="h-px flex-1 bg-primary/60" />
<div className="h-0 w-0 border-y-4 border-s-8 border-y-transparent border-s-primary/60" />
</div>
) : null}
<button
type="button"
onClick={() => onSelect(step.id)}
className={cn(
"w-40 rounded-lg border bg-card px-3 py-2 text-start shadow-sm transition-colors hover:bg-accent",
selectedStepId === step.id && "border-primary ring-2 ring-primary/30",
)}
aria-pressed={selectedStepId === step.id}
>
<span className="block text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{index + 1}. {t("processing.modelBuilder.stepKindTransform")}
</span>
<span className="block truncate text-sm font-medium">
{tool?.name ?? step.toolId}
</span>
</button>
</div>
);
})}
</div>
)}
</div>
);
}

interface StepCardProps {
step: ProcessingModelStep;
index: number;
Expand All @@ -734,6 +859,8 @@ interface StepCardProps {
onParamChange: (paramId: string, value: unknown) => void;
onRemove: () => void;
onMove: (dir: -1 | 1) => void;
selected: boolean;
onSelect: () => void;
}

/** One step in the model editor: its tool, parameters, and reorder controls. */
Expand All @@ -746,6 +873,8 @@ function StepCard({
onParamChange,
onRemove,
onMove,
selected,
onSelect,
}: StepCardProps): ReactElement {
const { t } = useTranslation();
const tool = getVectorTool(step.toolId);
Expand Down Expand Up @@ -775,16 +904,30 @@ function StepCard({
};

return (
<div className="rounded-md border p-2">
<div
className={cn("rounded-md border p-2", selected && "border-primary ring-1 ring-primary")}
onClick={onSelect}
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
<button
type="button"
className="truncate rounded text-start text-sm font-medium"
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
aria-pressed={selected}
>
{index + 1}. {tool?.name ?? step.toolId}
</span>
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-muted-foreground hover:bg-accent disabled:opacity-30"
onClick={() => onMove(-1)}
onClick={(event) => {
event.stopPropagation();
onMove(-1);
}}
disabled={index === 0}
aria-label={t("processing.modelBuilder.moveStepUp")}
>
Expand All @@ -793,7 +936,10 @@ function StepCard({
<button
type="button"
className="rounded p-1 text-muted-foreground hover:bg-accent disabled:opacity-30"
onClick={() => onMove(1)}
onClick={(event) => {
event.stopPropagation();
onMove(1);
}}
disabled={index === total - 1}
aria-label={t("processing.modelBuilder.moveStepDown")}
>
Expand All @@ -802,7 +948,10 @@ function StepCard({
<button
type="button"
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onRemove}
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
aria-label={t("processing.modelBuilder.removeStep")}
>
<Trash2 className="h-3.5 w-3.5" />
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -4312,6 +4312,11 @@
"addStep": "إضافة خطوة",
"runModel": "تشغيل النموذج",
"deleteModel": "حذف",
"canvas": "لوحة سير العمل المكاني",
"importPipeline": "استيراد خط الأنابيب",
"exportPipeline": "تصدير خط الأنابيب",
"stepKindTransform": "تحويل",
"canvasEmpty": "لا توجد خطوات بعد — أضف خطوة لبناء سير العمل.",
"inputPreviousStep": "الإدخال: → ناتج الخطوة السابقة",
"unknownTool": "أداة غير معروفة \"{{id}}\"",
"noParameters": "لا توجد معاملات."
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -4045,6 +4045,11 @@
"addStep": "Schritt hinzufügen",
"runModel": "Modell ausführen",
"deleteModel": "Löschen",
"canvas": "Arbeitsablauf-Canvas",
"importPipeline": "Pipeline importieren",
"exportPipeline": "Pipeline exportieren",
"stepKindTransform": "Transformation",
"canvasEmpty": "Noch keine Schritte — fügen Sie einen hinzu, um den Arbeitsablauf aufzubauen.",
"inputPreviousStep": "Eingabe: ← Ausgabe des vorherigen Schritts",
"unknownTool": "Unbekanntes Werkzeug „{{id}}“",
"noParameters": "Keine Parameter."
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4055,6 +4055,11 @@
"addStep": "Add step",
"runModel": "Run model",
"deleteModel": "Delete",
"canvas": "Spatial workflow canvas",
"importPipeline": "Import pipeline",
"exportPipeline": "Export pipeline",
"stepKindTransform": "Transform",
"canvasEmpty": "No steps yet — add one to build the workflow.",
"inputPreviousStep": "Input: ← previous step output",
"unknownTool": "Unknown tool \"{{id}}\"",
"noParameters": "No parameters."
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4045,6 +4045,11 @@
"addStep": "Añadir paso",
"runModel": "Ejecutar el modelo",
"deleteModel": "Eliminar",
"canvas": "Lienzo de flujo de trabajo espacial",
"importPipeline": "Importar canalización",
"exportPipeline": "Exportar canalización",
"stepKindTransform": "Transformación",
"canvasEmpty": "Aún no hay pasos: añade uno para crear el flujo de trabajo.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"inputPreviousStep": "Entrada: ← salida del paso anterior",
"unknownTool": "Herramienta desconocida «{{id}}»",
"noParameters": "Sin parámetros."
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -4045,6 +4045,11 @@
"addStep": "افزودن گام",
"runModel": "اجرای مدل",
"deleteModel": "حذف",
"canvas": "بوم گردش کار مکانی",
"importPipeline": "درون‌ریزی خط لوله",
"exportPipeline": "برون‌بری خط لوله",
"stepKindTransform": "تبدیل",
"canvasEmpty": "هنوز مرحله‌ای وجود ندارد — برای ساخت گردش کار یکی اضافه کنید.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"inputPreviousStep": "ورودی: → خروجی گام پیشین",
"unknownTool": "ابزار ناشناختهٔ «{{id}}»",
"noParameters": "بدون پارامتر."
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4045,6 +4045,11 @@
"addStep": "Ajouter une étape",
"runModel": "Exécuter le modèle",
"deleteModel": "Supprimer",
"canvas": "Canevas de flux de travail spatial",
"importPipeline": "Importer le pipeline",
"exportPipeline": "Exporter le pipeline",
"stepKindTransform": "Transformation",
"canvasEmpty": "Aucune étape pour l'instant — ajoutez-en une pour créer le flux de travail.",
"inputPreviousStep": "Entrée : ← sortie de l'étape précédente",
"unknownTool": "Outil inconnu « {{id}} »",
"noParameters": "Aucun paramètre."
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4045,6 +4045,11 @@
"addStep": "चरण जोड़ें",
"runModel": "मॉडल चलाएँ",
"deleteModel": "हटाएँ",
"canvas": "स्थानिक वर्कफ़्लो कैनवास",
"importPipeline": "पाइपलाइन आयात करें",
"exportPipeline": "पाइपलाइन निर्यात करें",
"stepKindTransform": "रूपांतरण",
"canvasEmpty": "अभी कोई चरण नहीं — वर्कफ़्लो बनाने के लिए एक जोड़ें।",
"inputPreviousStep": "इनपुट: ← पिछले चरण का आउटपुट",
"unknownTool": "अज्ञात टूल \"{{id}}\"",
"noParameters": "कोई पैरामीटर नहीं।"
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -3978,6 +3978,11 @@
"addStep": "Tambah langkah",
"runModel": "Jalankan model",
"deleteModel": "Hapus",
"canvas": "Kanvas alur kerja spasial",
"importPipeline": "Impor pipeline",
"exportPipeline": "Ekspor pipeline",
"stepKindTransform": "Transformasi",
"canvasEmpty": "Belum ada langkah — tambahkan satu untuk membangun alur kerja.",
"inputPreviousStep": "Masukan: ← keluaran langkah sebelumnya",
"unknownTool": "Alat tidak dikenal \"{{id}}\"",
"noParameters": "Tidak ada parameter."
Expand Down
Loading
Loading