Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
type DistanceUnit,
} from "../../lib/whitebox-distance-params";
import { parameterKind } from "../../lib/whitebox-param-kind";
import { isTiff } from "../../lib/scripting/binary-output";
import {
canUseLayerForParameter,
fetchLayerBytes,
Expand Down Expand Up @@ -140,12 +141,13 @@ function isOutputParameter(param: WhiteboxToolParameter): boolean {
/**
* Best-effort extension for a binary tool output, sniffed from its magic bytes.
* Covers the formats GeoLibre `file_out` and (CRS-preserving) `vector_out` tools
* emit today (GeoParquet, FlatGeobuf, zipped Shapefile, PNG, PMTiles); a
* emit today (GeoTIFF, GeoParquet, FlatGeobuf, zipped Shapefile, PNG, PMTiles); a
* genuinely opaque output falls back to `.bin`. Extend the sniff here if a
* future tool writes a recognizable format.
*/
function fileOutputExtension(bytes: Uint8Array): string {
const matches = (sig: number[]) => sig.every((b, i) => bytes[i] === b);
if (isTiff(bytes)) return "tif";
if (matches([0x50, 0x41, 0x52, 0x31])) return "parquet"; // "PAR1"
if (matches([0x66, 0x67, 0x62, 0x03])) return "fgb"; // FlatGeobuf "fgb\x03"
if (matches([0x50, 0x4b, 0x03, 0x04])) return "zip"; // Shapefile bundle "PK\x03\x04"
Expand Down Expand Up @@ -1413,13 +1415,18 @@ export function ProcessingDialog({ mapControllerRef, onAddRaster }: ProcessingDi
// become a new raster layer; a `file_out` (e.g. write_geoparquet .parquet,
// a rendered .png, a .pmtiles) or a CRS-preserving `vector_out`
// (GeoParquet/FlatGeobuf/zipped Shapefile, chosen to keep a reprojection's
// target CRS) is not a GeoTIFF, so download it instead of handing it to the
// raster loader.
// target CRS) is downloaded instead — unless its bytes turn out to be a
// GeoTIFF after all, which several tools declare only as a generic file.
for (const [name, value] of Object.entries(nextJob.outputs)) {
if (!(value instanceof Uint8Array)) continue;
const param = jobTool?.params?.find((item) => item.name === name);
const outKind = param ? parameterKind(param) : "";
if (outKind === "file_out" || outKind === "vector_out") {
// A generic `file_out` can still hold a GeoTIFF — `slope` declares its
// output only as "Optional output path" — so sniff the bytes rather
// than trust the declared kind, the way the scripting/assistant path
// does, and put a raster on the map instead of downloading it.
const declaredFile = outKind === "file_out" || outKind === "vector_out";
if (declaredFile && (!isTiff(value) || !onAddRaster)) {
const label = `${jobToolLabel} ${humanize(name)}`.replace(/\s+/g, "_");
// Prefer the content signature: a `vector_out` and most binary
// `file_out` formats (GeoParquet/FlatGeobuf/zipped Shapefile/PNG/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,13 @@ function cardLayout(
const rows = Math.max(ports.inputs.length, ports.outputs.length);
// A labelled card is always a little taller than a bare one: at the compact
// height a single row's text sits hard against the title above and the card
// edge below.
// edge below. The synthetic input/output cards carry no port label, but
// sizing them bare would leave them visibly shorter than every tool card
// beside them, so they take the height of a one-row labelled tool and a
// chain lines up.
const height =
labelIn || labelOut
? Math.max(NODE_HEIGHT + 6, CARD_HEADER_HEIGHT + rows * PORT_ROW_HEIGHT + 8)
labelIn || labelOut || kind !== "tool"
? Math.max(NODE_HEIGHT + 6, CARD_HEADER_HEIGHT + Math.max(rows, 1) * PORT_ROW_HEIGHT + 8)
: NODE_HEIGHT;
// Both sides share one vertical band. A labelled side fills it row by row; an
// unlabelled side spreads its dots down the same band rather than down the
Expand All @@ -205,7 +208,7 @@ function cardLayout(
const band: PortBand =
labelIn || labelOut
? { top: CARD_HEADER_HEIGHT, height: rows * PORT_ROW_HEIGHT }
: { top: 0, height: NODE_HEIGHT };
: { top: 0, height };
return { height, labelIn, labelOut, band };
}

Expand Down Expand Up @@ -255,7 +258,9 @@ export function ModelBuilderPanel({
}: ModelBuilderPanelProps): ReactElement | null {
const { t } = useTranslation();
const open = useAppStore((s) => s.ui.modelBuilderOpen);
const requestedModelId = useAppStore((s) => s.ui.modelBuilderRequestedModelId);
const setOpen = useAppStore((s) => s.setModelBuilderOpen);
const setRequestedModelId = useAppStore((s) => s.setModelBuilderRequestedModelId);
const layers = useAppStore((s) => s.layers);
const savedModels = useAppStore((s) => s.models);
const saveModel = useAppStore((s) => s.saveModel);
Expand Down Expand Up @@ -464,11 +469,17 @@ export function ModelBuilderPanel({
* the current model away, so each asks first rather than discarding unsaved
* work silently. `window.confirm` is blocking and matches how the rest of
* the app confirms a discard (see PythonEditorPane).
*
* A run in flight is unsaved work of a different kind — `resetRunState`
* aborts it — and a saved, unmodified model is not `dirty`, so it needs its
* own prompt. That matters most for the AI Assistant, which can request a
* model load the user never clicked; without this a background trigger would
* kill a running job with no confirmation at all.
*/
const confirmDiscard = useCallback(
() => !dirty || window.confirm(t("processing.modelBuilder.discardChanges")),
[dirty, t],
);
const confirmDiscard = useCallback(() => {
if (running && !window.confirm(t("processing.modelBuilder.discardRunning"))) return false;
return !dirty || window.confirm(t("processing.modelBuilder.discardChanges"));
}, [dirty, running, t]);

/**
* Room the layout gets to work with: the canvas's own visible width, so a
Expand All @@ -490,18 +501,46 @@ export function ModelBuilderPanel({
resetRunState();
}, [confirmDiscard, resetRunState]);

/** @returns False when the user declined to discard what was on the canvas. */
const handleLoadModel = useCallback(
(model: ProcessingModel) => {
if (!confirmDiscard()) return;
(model: ProcessingModel): boolean => {
if (!confirmDiscard()) return false;
setModelId(model.id);
setModelName(model.name);
setGraph(autoLayout(model.graph ?? stepsToGraph(model), layoutOptions()));
setSelectedNodeId(null);
resetRunState();
return true;
},
[confirmDiscard, layoutOptions, resetRunState],
);

/** Re-run the depth-based layout over the nodes the user has moved around. */
const handleArrange = useCallback(() => {
setGraph((current) => layoutGraph(current, layoutOptions()));
// The layout starts at the origin, so a canvas left scrolled somewhere
// else would open on empty space right after tidying it up.
canvasRef.current?.scrollTo({ left: 0, top: 0 });
}, [layoutOptions]);

// Programmatic entry points (notably the AI Assistant) save a normal project
// model and request that it be shown. Reuse the regular load path so an
// unsaved canvas still receives its discard confirmation.
useEffect(() => {
if (!open || !requestedModelId) return;
const requested = savedModels.find((model) => model.id === requestedModelId);
setRequestedModelId(null);
if (!requested || !handleLoadModel(requested)) return;
// This request usually opens the panel, so on this pass the canvas is
// still mounting and the `layoutOptions()` inside handleLoadModel measures
// a width of zero — the layout then falls back to its default and a long
// assistant-built chain lands off the visible area. Re-arrange on the next
// frame, once the canvas has real width, so the model appears tidied and
// scrolled to its start rather than needing a manual Arrange.
const frame = requestAnimationFrame(() => handleArrange());
return () => cancelAnimationFrame(frame);
}, [open, requestedModelId, savedModels, setRequestedModelId, handleLoadModel, handleArrange]);

/**
* Forget the loaded model. The picker only offers models the project already
* holds, so the button is meaningful exactly when the open model is one of
Expand All @@ -526,14 +565,6 @@ export function ModelBuilderPanel({
});
}, []);

/** Re-run the depth-based layout over the nodes the user has moved around. */
const handleArrange = useCallback(() => {
setGraph((current) => layoutGraph(current, layoutOptions()));
// The layout starts at the origin, so a canvas left scrolled somewhere
// else would open on empty space right after tidying it up.
canvasRef.current?.scrollTo({ left: 0, top: 0 });
}, [layoutOptions]);

const handleSave = useCallback(() => {
// Also write the legacy linear projection when the graph happens to be a
// single vector chain, so a build without the canvas can still run it.
Expand Down Expand Up @@ -1899,7 +1930,14 @@ const GraphNodeCard = memo(function GraphNodeCard({
}}
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",
"absolute cursor-grab select-none border p-2 shadow-sm active:cursor-grabbing",
// A tool is a square-cornered card; the model's own inputs and outputs
// are rounded and tinted, so the data flowing in and out reads apart
// from the processing between it at a glance — the same split ArcGIS
// ModelBuilder and QGIS draw as ovals versus rectangles. Safe for
// exactly these two kinds: each has a single, vertically centred port,
// so no port dot ever lands on the rounded part of the edge.
node.kind === "tool" ? "rounded-md bg-card" : "rounded-xl border-primary/40 bg-primary/10",
selected && "border-primary ring-2 ring-primary/30",
hasIssue && !selected && "border-destructive",
status === "running" && "ring-2 ring-primary",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -4319,6 +4319,7 @@
"untitledModel": "نموذج بلا عنوان",
"newModel": "جديد",
"discardChanges": "هل تريد تجاهل التغييرات غير المحفوظة في النموذج الحالي؟",
"discardRunning": "لا يزال تشغيل النموذج جاريًا. هل تريد إيقافه والمتابعة؟",
"arrange": "ترتيب",
"arrangeHint": "ترتيب العقد على امتداد مسار التدفق",
"runModel": "تشغيل",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Unbenanntes Modell",
"newModel": "Neu",
"discardChanges": "Nicht gespeicherte Änderungen am aktuellen Modell verwerfen?",
"discardRunning": "Ein Modelllauf läuft noch. Anhalten und fortfahren?",
"arrange": "Anordnen",
"arrangeHint": "Die Knoten entlang des Ablaufs anordnen",
"runModel": "Ausführen",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4062,6 +4062,7 @@
"untitledModel": "Untitled model",
"newModel": "New",
"discardChanges": "Discard unsaved changes to the current model?",
"discardRunning": "A model run is still in progress. Stop it and continue?",
"arrange": "Arrange",
"arrangeHint": "Lay the nodes out along the flow",
"runModel": "Run",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Modelo sin título",
"newModel": "Nuevo",
"discardChanges": "¿Descartar los cambios sin guardar del modelo actual?",
"discardRunning": "Todavía se está ejecutando un modelo. ¿Detenerlo y continuar?",
"arrange": "Organizar",
"arrangeHint": "Distribuir los nodos siguiendo el flujo",
"runModel": "Ejecutar",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "مدل بدون عنوان",
"newModel": "جدید",
"discardChanges": "تغییرات ذخیره‌نشدهٔ مدل کنونی دور انداخته شوند؟",
"discardRunning": "اجرای مدل هنوز در جریان است. متوقف شود و ادامه دهیم؟",
"arrange": "چیدمان",
"arrangeHint": "چیدن گره‌ها در امتداد جریان",
"runModel": "اجرا",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Modèle sans titre",
"newModel": "Nouveau",
"discardChanges": "Annuler les modifications non enregistrées du modèle actuel ?",
"discardRunning": "Une exécution du modèle est encore en cours. L’arrêter et continuer ?",
"arrange": "Organiser",
"arrangeHint": "Disposer les nœuds le long du flux",
"runModel": "Exécuter",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "बिना शीर्षक मॉडल",
"newModel": "नया",
"discardChanges": "वर्तमान मॉडल में असेव्ड बदलाव त्यागें?",
"discardRunning": "मॉडल अभी भी चल रहा है। इसे रोककर आगे बढ़ें?",
"arrange": "व्यवस्थित करें",
"arrangeHint": "नोड्स को प्रवाह के अनुसार व्यवस्थित करें",
"runModel": "चलाएँ",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -3985,6 +3985,7 @@
"untitledModel": "Model tanpa judul",
"newModel": "Baru",
"discardChanges": "Buang perubahan yang belum disimpan pada model saat ini?",
"discardRunning": "Model masih berjalan. Hentikan dan lanjutkan?",
"arrange": "Tata",
"arrangeHint": "Menata simpul mengikuti alur",
"runModel": "Jalankan",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Modello senza titolo",
"newModel": "Nuovo",
"discardChanges": "Scartare le modifiche non salvate del modello corrente?",
"discardRunning": "Un'esecuzione del modello è ancora in corso. Interromperla e continuare?",
"arrange": "Disponi",
"arrangeHint": "Dispone i nodi lungo il flusso",
"runModel": "Esegui",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -3985,6 +3985,7 @@
"untitledModel": "名称未設定のモデル",
"newModel": "新規",
"discardChanges": "現在のモデルの未保存の変更を破棄しますか?",
"discardRunning": "モデルの実行がまだ進行中です。停止して続行しますか?",
"arrange": "整列",
"arrangeHint": "ノードを処理の流れに沿って並べます",
"runModel": "実行",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/ka.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "უსათაურო მოდელი",
"newModel": "ახალი",
"discardChanges": "უარვყოთ მიმდინარე მოდელის შეუნახავი ცვლილებები?",
"discardRunning": "მოდელის გაშვება ჯერ კიდევ მიმდინარეობს. შევაჩეროთ და გავაგრძელოთ?",
"arrange": "დალაგება",
"arrangeHint": "კვანძების დალაგება ნაკადის მიმართულებით",
"runModel": "გაშვება",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -3985,6 +3985,7 @@
"untitledModel": "제목 없는 모델",
"newModel": "새로 만들기",
"discardChanges": "현재 모델의 저장되지 않은 변경 사항을 취소하시겠습니까?",
"discardRunning": "모델 실행이 아직 진행 중입니다. 중지하고 계속하시겠습니까?",
"arrange": "정렬",
"arrangeHint": "노드를 처리 흐름에 따라 배치합니다",
"runModel": "실행",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Naamloos model",
"newModel": "Nieuw",
"discardChanges": "Niet-opgeslagen wijzigingen aan het huidige model verwerpen?",
"discardRunning": "Er wordt nog een model uitgevoerd. Stoppen en doorgaan?",
"arrange": "Ordenen",
"arrangeHint": "De knooppunten langs de stroom ordenen",
"runModel": "Uitvoeren",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Modelo sem título",
"newModel": "Novo",
"discardChanges": "Descartar alterações não guardadas no modelo atual?",
"discardRunning": "Ainda está a decorrer uma execução do modelo. Parar e continuar?",
"arrange": "Organizar",
"arrangeHint": "Dispor os nós ao longo do fluxo",
"runModel": "Executar",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -4186,6 +4186,7 @@
"untitledModel": "Модель без названия",
"newModel": "Создать",
"discardChanges": "Отменить несохранённые изменения текущей модели?",
"discardRunning": "Выполнение модели ещё не завершено. Остановить и продолжить?",
"arrange": "Упорядочить",
"arrangeHint": "Расставить узлы вдоль потока обработки",
"runModel": "Запустить",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/th.json
Original file line number Diff line number Diff line change
Expand Up @@ -3985,6 +3985,7 @@
"untitledModel": "แบบจำลองไม่มีชื่อ",
"newModel": "ใหม่",
"discardChanges": "ทิ้งการเปลี่ยนแปลงที่ยังไม่ได้บันทึกของแบบจำลองปัจจุบันหรือไม่?",
"discardRunning": "การเรียกใช้แบบจำลองยังดำเนินอยู่ ต้องการหยุดและดำเนินการต่อหรือไม่?",
"arrange": "จัดเรียง",
"arrangeHint": "จัดเรียงโหนดตามลำดับการไหลของงาน",
"runModel": "เรียกใช้",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4052,6 +4052,7 @@
"untitledModel": "Adsız model",
"newModel": "Yeni",
"discardChanges": "Mevcut modelin kaydedilmemiş değişiklikleri atılsın mı?",
"discardRunning": "Bir model çalışması hâlâ sürüyor. Durdurulup devam edilsin mi?",
"arrange": "Düzenle",
"arrangeHint": "Düğümleri akış boyunca dizer",
"runModel": "Çalıştır",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4071,6 +4071,7 @@
"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?",
"discardRunning": "Một lần chạy mô hình vẫn đang diễn ra. Dừng lại và tiếp tục?",
"arrange": "Sắp xếp",
"arrangeHint": "Sắp xếp các nút theo dòng xử lý",
"runModel": "Chạy",
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -3985,6 +3985,7 @@
"untitledModel": "未命名模型",
"newModel": "新建",
"discardChanges": "是否放弃当前模型的未保存更改?",
"discardRunning": "模型运行尚未结束。是否停止并继续?",
"arrange": "排列",
"arrangeHint": "沿处理流程排列节点",
"runModel": "运行",
Expand Down
Loading
Loading