Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
130 changes: 130 additions & 0 deletions openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,136 @@
}
}
},
{
"name": "planned_path.history",
"summary": "List a planned-path job's steps (metadata only); fetch each area's geometry via planned_path.history.step.",
"paramStructure": "by-name",
"params": [
{
"name": "job_id",
"description": "Job ID to fetch the planned path for. Omit to get the current/live job.",
"required": false,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "object",
"properties": {
"job_id": {
"type": "string"
},
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"step_index": {
"type": "number"
},
"area_id": {
"type": "string"
},
"angle": {
"type": "number"
},
"offset": {
"type": "number"
}
}
}
}
}
}
}
},
{
"name": "planned_path.history.step",
"summary": "Returns one area/step's planned-path geometry for a job (bounded per-area payload).",
"paramStructure": "by-name",
"params": [
{
"name": "job_id",
"description": "Job ID.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "step_index",
"description": "Ordinal step/area index within the job.",
"required": true,
"schema": {
"type": "number"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "object",
"properties": {
"job_id": {
"type": "string"
},
"step_index": {
"type": "number"
},
"paths": {
"type": "array",
"items": {
"type": "object",
"properties": {
"is_outline": {
"type": "boolean"
},
"points": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
}
}
}
}
}
}
},
{
"name": "planned_path.history.list",
"summary": "List available planned-path job history entries.",
"params": [],
"result": {
"name": "jobs",
"description": "Array of job history entries, each with a job_id and epoch timestamp. Sorted newest first.",
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"job_id": {
"type": "string"
},
"timestamp": {
"type": "number"
}
},
"required": [
"job_id",
"timestamp"
]
}
}
}
},
{
"name": "events.history",
"summary": "Returns events for today when called without params, or events for a specific date.",
Expand Down
14 changes: 14 additions & 0 deletions src/components/map/LayersButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ export default function LayersButton({datum, trackLoading, editMode}: LayersButt
const {
showSatelliteLayer,
showTrackLayer,
showPlannedPath,
selectedJobId,
setShowSatelliteLayer,
setShowTrackLayer,
setShowPlannedPath,
setSelectedJobId,
} = useMapDisplayStore();

Expand Down Expand Up @@ -134,6 +136,18 @@ export default function LayersButton({datum, trackLoading, editMode}: LayersButt
label="Satellite"
/>

<FormControlLabel
sx={{mx: 0, px: 1, py: 0.5, width: '100%'}}
control={
<Switch
checked={showPlannedPath && !editMode}
onChange={(e) => setShowPlannedPath(e.target.checked)}
disabled={editMode}
/>
}
label="Planned path"
/>

{hasPositionCapability && (
<>
<FormControlLabel
Expand Down
7 changes: 6 additions & 1 deletion src/components/map/MowerMap.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import {useFitToBounds, useMapboxDraw, useMapContext, useMapHover} from '@/contexts/MapContext';
import {useJobPlannedPath} from '@/hooks/useJobPlannedPath';
import {useJobTrack} from '@/hooks/useJobTrack';
import {useMapDisplayStore} from '@/stores/mapDisplayStore';
import {useSelectedMower} from '@/stores/mowersStore';
Expand Down Expand Up @@ -33,6 +34,7 @@ import LayersButton from './LayersButton';
import MapDialog from './MapDialog';
import {mapStyles} from './mapStyles';
import MowerMarker from './MowerMarker';
import PlannedPathLayer from './PlannedPathLayer';
import TeleopControls from './teleop/TeleopControls';
import TrackLayer from './TrackLayer';

Expand Down Expand Up @@ -68,8 +70,10 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) {
);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const {showSatelliteLayer, showTrackLayer, showAreaList, selectedJobId, setShowAreaList} = useMapDisplayStore();
const {showSatelliteLayer, showTrackLayer, showPlannedPath, showAreaList, selectedJobId, setShowAreaList} =
useMapDisplayStore();
const {pastTrack, loading: trackLoading} = useJobTrack(selectedJobId);
const {plannedPath} = useJobPlannedPath(selectedJobId);
const areaSettingsDialog = useDialog(AreaSettingsDialog);
const padding = useMemo(() => ({top: 10, bottom: 10, left: 60, right: showAreaList ? 390 : 60}), [showAreaList]);
const fitToBounds = useFitToBounds();
Expand Down Expand Up @@ -294,6 +298,7 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) {
<DockingStationMarker key={station.id} station={station} datum={datumOrFallback} isDocked={isDocked} />
))}
{mowerPosition && !isDocked && <MowerMarker position={mowerPosition} datum={datumOrFallback} />}
<PlannedPathLayer visible={showPlannedPath && !editMode} datum={datumOrFallback} plannedPath={plannedPath} />
<TrackLayer visible={showTrackLayer && !editMode} pastTrack={pastTrack} loading={trackLoading} />
{showTeleop && <TeleopControls />}
<DialogOutlet />
Expand Down
62 changes: 62 additions & 0 deletions src/components/map/PlannedPathLayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client';

import type {Datum, PlannedPath} from '@/stores/schemas';
import {datumToRelative, pointToAbsolute} from '@/utils/coordinates';
import {featureCollection} from '@turf/helpers';
import type {Feature, FeatureCollection, LineString} from 'geojson';
import type {ExpressionSpecification, LineLayerSpecification} from 'maplibre-gl';
import {RLayer, RSource} from 'maplibre-react-components';
import {useMemo} from 'react';

type PathProps = {is_outline: boolean};

// Interior fill passes: solid grey. Outline (perimeter) passes: lighter, dashed.
const linePaint: LineLayerSpecification['paint'] = {
'line-color': ['case', ['==', ['get', 'is_outline'], true], '#aaaaaa', '#888888'] as ExpressionSpecification,
'line-width': 1.5,
'line-opacity': ['case', ['==', ['get', 'is_outline'], true], 0.7, 0.8] as ExpressionSpecification,
'line-dasharray': ['case', ['==', ['get', 'is_outline'], true], ['literal', [2, 2]], ['literal', [1, 0]]],
};

const emptyCollection: FeatureCollection<LineString, PathProps> = featureCollection([]);

interface PlannedPathLayerProps {
visible?: boolean;
datum: Datum | null;
// The whole-job plan to draw (fetched from history for the current or a selected past job).
plannedPath?: PlannedPath | null;
}

/**
* Planned-path layer: renders the slic3r planned mowing path as grey lines in the map frame.
* The plan is fetched from history (via useJobPlannedPath) for whichever job is shown -- current or
* a selected past one -- so it stays visible after the job ends. Outline (perimeter) passes are
* drawn lighter/dashed. Rendered declaratively as a GeoJSON FeatureCollection of LineStrings.
*/
export default function PlannedPathLayer({visible = true, datum, plannedPath = null}: PlannedPathLayerProps) {
const data = useMemo<FeatureCollection<LineString, PathProps>>(() => {
if (!plannedPath || !datum) return emptyCollection;
const utm = datumToRelative([datum.long, datum.lat]);
const features: Feature<LineString, PathProps>[] = plannedPath.paths
.filter((path) => path.points.length >= 2)
.map((path) => ({
type: 'Feature',
properties: {is_outline: path.is_outline},
geometry: {
type: 'LineString',
coordinates: path.points.map(([x, y]) => pointToAbsolute({x, y}, utm)),
},
}));
return featureCollection(features);
}, [plannedPath, datum]);

const visibility = visible ? 'visible' : 'none';
const layout: LineLayerSpecification['layout'] = {'line-join': 'round', 'line-cap': 'round', visibility};

return (
<>
<RSource id="planned-path-source" type="geojson" data={data} />
<RLayer id="planned-path-layer" source="planned-path-source" type="line" layout={layout} paint={linePaint} />
</>
);
}
99 changes: 99 additions & 0 deletions src/hooks/useJobPlannedPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {useSelectedMower} from '@/stores/mowersStore';
import {plannedPathSchema, type PlannedPath} from '@/stores/schemas';
import {useCallback, useEffect, useRef, useState} from 'react';

type FetchState =
| {status: 'idle'}
| {status: 'loading'; jobId: string}
| {status: 'loaded'; jobId: string; planned: PlannedPath | null}
| {status: 'error'; jobId: string};

/**
* Fetches a job's whole planned path from history (one area at a time) and assembles it client-side.
*
* Used for BOTH a selected past job AND the current/live job: the live MQTT topic is only a
* {job_id, step_index} signal, so the plan is fetched and held client-side and therefore stays
* visible after the job is cancelled/finished, exactly like the driven track. The signal picks the
* current job and triggers a re-fetch when a new area is planned (step_index advances).
*/
export function useJobPlannedPath(selectedJobId: string | null): {plannedPath: PlannedPath | null; loading: boolean} {
const rpc = useSelectedMower((s) => s?.rpc);
const liveJobId = useSelectedMower((s) => s?.plannedPathSignal?.job_id ?? null);
const liveStep = useSelectedMower((s) => s?.plannedPathSignal?.step_index ?? null);

// The job to display: an explicitly selected past job, else the current/live job.
const jobId = selectedJobId ?? liveJobId;
const isLive = jobId !== null && jobId === liveJobId;

// Cache assembled plans by job_id. Past jobs are immutable; the live job grows as it mows, so it
// is always re-fetched (its cache entry is overwritten each time).
const cache = useRef<Map<string, PlannedPath | null>>(new Map());
const latestJobId = useRef<string | null>(null);
const [state, setState] = useState<FetchState>({status: 'idle'});

const fetchJob = useCallback(
async (id: string, useCache: boolean) => {
if (!rpc) return;
latestJobId.current = id;

if (useCache) {
const cached = cache.current.get(id);
if (cached !== undefined) {
setState({status: 'loaded', jobId: id, planned: cached});
return;
}
}

// Only show a spinner if we have nothing to display for this job yet; a live-job re-fetch keeps
// the current plan on screen (no flicker) until the grown plan arrives.
setState((prev) => (prev.status === 'loaded' && prev.jobId === id ? prev : {status: 'loading', jobId: id}));
try {
// Fetch the job's step list (metadata only), then each area's geometry separately so no single
// MQTT message carries the whole job. Fetch in small batches and do NOT swallow a real failure
// (timeout / transport) -- that fails the load rather than rendering a partial path. A
// genuinely empty/missing step resolves to [] (the backend returns it as a result).
const stepList = await rpc.planned_path.history({job_id: id});
const stepIndices = (stepList?.steps ?? [])
.map((s) => s.step_index)
.filter((i): i is number => typeof i === 'number');

const batches: unknown[][] = [];
const BATCH = 6;
for (let i = 0; i < stepIndices.length; i += BATCH) {
const batch = await Promise.all(
stepIndices
.slice(i, i + BATCH)
.map((step_index) =>
rpc.planned_path.history.step({job_id: id, step_index}).then((r) => (r?.paths ?? []) as unknown[]),
),
);
batches.push(...batch);
}

const parsed = plannedPathSchema.safeParse({job_id: id, paths: batches.flat()});
const planned = parsed.success ? parsed.data : null;
cache.current.set(id, planned);
// Ignore the result if a newer job was selected while we were fetching.
if (latestJobId.current === id) setState({status: 'loaded', jobId: id, planned});
} catch {
if (latestJobId.current === id) setState({status: 'error', jobId: id});
}
},
[rpc],
);

useEffect(() => {
if (!jobId) {
latestJobId.current = null;
setState({status: 'idle'});
return;
}
// Past jobs are immutable -> use the cache. The live job grows per area -> re-fetch fresh, and
// re-run whenever its step advances (liveStep is a trigger dependency).
void fetchJob(jobId, !isLive);
}, [jobId, isLive, liveStep, fetchJob]);

if (!jobId) return {plannedPath: null, loading: false};
if (state.status === 'loaded' && state.jobId === jobId) return {plannedPath: state.planned, loading: false};
return {plannedPath: null, loading: true};
}
Loading
Loading