import React, {useEffect, useMemo, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {getJson, patchJson} from '../api';
import PlantSomethingModal from './PlantSomethingModal';
function isPlaced(plant) {
return plant.bed_x !== null && plant.bed_y !== null;
}
// A plant pulled off a stack has no id until the server saves it, so the client
// keeps its own key to tell circles apart in the meantime.
function keyOf(plant) {
return plant.key || `plant-${plant.id}`;
}
function withKeys(plantings) {
return plantings.map((planting) => ({
...planting,
plants: planting.plants.map((plant) => ({...plant, key: keyOf(plant)})),
}));
}
// The server reports per-plant errors keyed by id; any one of them explains why
// the whole arrangement was refused, since it saves all or nothing.
function firstError(data) {
const errors = data && data.errors;
if (!errors) return null;
const messages = Object.values(errors).flat().filter(Boolean);
return messages.length > 0 ? messages[0] : null;
}
function allPlants(plantings) {
return plantings.flatMap((planting) => planting.plants.map((plant) => ({plant, planting})));
}
// "3 Mar", for telling apart plantings of the same crop.
function plantedLabel(planting) {
if (!planting.planted_at) return null;
const date = new Date(`${planting.planted_at}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString(undefined, {day: 'numeric', month: 'short'});
}
// How many cells across a plant is drawn: its own size if it has been resized,
// otherwise its crop's default.
function diameterOf(plant, planting) {
return plant.diameter ?? planting.default_diameter ?? 1;
}
// Everything besides plants that can go on the bed, in the order the sidebar
// offers them, and how each is drawn. A point is an object with a size, centred
// on (x, y); a label is text at (x, y); a line runs from (x, y) to (x2, y2); an
// area is the rectangle with those two corners. Lines and areas share their
// handles: drag the middle to move, the ends or corners to reshape.
const FEATURE_TYPES = {
stone: {name: 'Stepping stone', shape: 'point', size: 0.8},
sprinkler: {name: 'Sprinkler', shape: 'point', size: 0.7},
tap: {name: 'Tap', shape: 'point', size: 0.6},
stake: {name: 'Stake', shape: 'point', size: 0.3},
label: {name: 'Label', shape: 'label', faIcon: 'fa-font'},
row: {name: 'Row', shape: 'line', faIcon: 'fa-grip-lines'},
path: {name: 'Path', shape: 'line', faIcon: 'fa-shoe-prints'},
dripline: {name: 'Drip line', shape: 'line', faIcon: 'fa-tint'},
fence: {name: 'Fence', shape: 'line', faIcon: 'fa-grip-lines-vertical'},
trellis: {name: 'Trellis', shape: 'line', faIcon: 'fa-border-all'},
netting: {name: 'Netting', shape: 'area', faIcon: 'fa-th'},
};
// How the features are grouped in the toolbar above the bed.
const FEATURE_GROUPS = [
{name: 'Ground', kinds: ['stone', 'path']},
{name: 'Water', kinds: ['sprinkler', 'tap', 'dripline']},
{name: 'Supports', kinds: ['stake', 'fence', 'trellis', 'netting']},
{name: 'Markers', kinds: ['label', 'row']},
];
function shapeOf(feature) {
return FEATURE_TYPES[feature.kind]?.shape;
}
// Lines and areas: the kinds with two points, moved by their middle.
function isSpan(feature) {
return ['line', 'area'].includes(shapeOf(feature));
}
// How big a feature is, for keeping it on the bed; labels and the ends of
// lines and areas are points.
function featureSize(feature) {
return FEATURE_TYPES[feature.kind]?.size || 0;
}
function featureName(kind) {
return FEATURE_TYPES[kind].name.toLowerCase();
}
function round3(value) {
return Number(value.toFixed(3));
}
// A line or area as it would be with one end or corner dragged elsewhere.
function withEnd(row, end, x, y) {
return end === 'start' ? {...row, x, y} : {...row, x2: x, y2: y};
}
// Ring colours for telling apart plantings drawn with the same icon: the five
// hues that stay distinguishable from each other on the soil, colour blind or
// not (checked against the soil for every pair, as any two plantings can end up
// side by side). Always given out in this order.
const RING_COLOURS = ['#2a78d6', '#1baf7a', '#c98500', '#008300', '#e34948'];
// planting id => ring colour. Only plantings that share their icon with another
// get one, in the order they were made, so a planting keeps its colour as
// others come and go. Past five in a group there's no colour left; those go by
// their names.
function ringColours(plantings) {
const groups = {};
plantings.forEach((planting) => {
(groups[planting.icon_key] ||= []).push(planting);
});
const colours = {};
Object.values(groups).filter((group) => group.length > 1).forEach((group) => {
[...group].sort((a, b) => a.id - b.id).forEach((planting, i) => {
if (i < RING_COLOURS.length) colours[planting.id] = RING_COLOURS[i];
});
});
return colours;
}
function clamp(value, low, high) {
return Math.min(Math.max(value, low), high);
}
// Keeps every plant wholly on the bed. A position is the plant's centre, so it
// has to stay half the plant's width from each edge, or a big plant near the
// edge hangs over the frame. A plant wider than the bed sits in the middle.
function fitPlants(plantings, garden) {
return plantings.map((planting) => ({
...planting,
plants: planting.plants.map((plant) => {
if (!isPlaced(plant)) return plant;
const radius = Math.min(diameterOf(plant, planting), garden.grid_columns, garden.grid_rows) / 2;
return {
...plant,
bed_x: Number(clamp(plant.bed_x, radius, garden.grid_columns - radius).toFixed(3)),
bed_y: Number(clamp(plant.bed_y, radius, garden.grid_rows - radius).toFixed(3)),
};
}),
}));
}
// What goes inside a plant's circle: the crop's icon.
function PlantFace({planting, labelled = false}) {
return ;
}
// Swapped in for the browser's own drag picture, which is a faint snapshot that
// all but vanishes on dark soil; the layout draws its own instead. Made once,
// up front, as setDragImage needs an image that has already loaded.
const invisibleDragImage = (() => {
if (typeof Image === 'undefined') return null;
const image = new Image();
image.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
return image;
})();
// A copy of a composted plant that arcs from where it was dropped into the bin,
// shrinking and spinning as it goes. Drawn over the page, then removed.
function CompostGhost({ghost, onDone}) {
const ref = useRef(null);
useEffect(() => {
const dx = ghost.to.x - ghost.from.x;
const dy = ghost.to.y - ghost.from.y;
const at = (fraction, lift) => `translate(calc(-50% + ${dx * fraction}px), calc(-50% + ${dy * fraction - lift}px))`;
const animation = ref.current.animate([
{transform: `${at(0, 0)} scale(1) rotate(0deg)`, opacity: 1},
{transform: `${at(0.5, 60)} scale(0.8) rotate(100deg)`, opacity: 1, offset: 0.45},
{transform: `${at(1, 0)} scale(0.15) rotate(240deg)`, opacity: 0.3},
], {duration: 600, easing: 'cubic-bezier(0.45, 0.05, 0.55, 0.95)'});
animation.onfinish = onDone;
return () => animation.cancel();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
);
}
function prefersReducedMotion() {
return Boolean(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
// The bed, with one circle per plant. A plant's position is its centre, in grid
// cells, and it is a float: a plant goes exactly where it is dropped, and the
// grid is there to measure and line things up by, not a set of slots.
export default function GardenLayout({
garden: initialGarden, editable, resizable, plantable, max_grid_size: maxGridSize, max_diameter: maxDiameter,
save_url: saveUrl,
layout_url: layoutUrl, spade_icon_url: spadeIconUrl, compost_icon_url: compostIconUrl, plantings: initialPlantings,
features: initialFeatures = [], feature_icon_urls: featureIcons = {},
}) {
const [planting, setPlanting] = useState(false);
// The bed's size is edited here too, and redraws as it's typed.
const [garden, setGarden] = useState(initialGarden);
const savedSize = useRef({columns: initialGarden.grid_columns, rows: initialGarden.grid_rows});
const resizeTimer = useRef(null);
const [plantings, setPlantings] = useState(() => fitPlants(withKeys(initialPlantings), initialGarden));
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
const [notice, setNotice] = useState(null);
const [ghosts, setGhosts] = useState([]);
const [gulping, setGulping] = useState(false);
const binIcon = useRef(null);
const ghostCount = useRef(0);
const gulpTimer = useRef(null);
const [dragging, setDragging] = useState(null);
const lastSaved = useRef(fitPlants(withKeys(initialPlantings), initialGarden));
const newPlants = useRef(0);
// The soil inside the bed's frame. Drops are measured against this, not the
// whole bed, so a plant lands where it's let go rather than a frame's width off.
const soilRef = useRef(null);
// A resize in progress: which plant, where its centre is on screen, and how
// big a cell is, so the pointer's distance from the centre gives a diameter.
const resizing = useRef(null);
const [liveSize, setLiveSize] = useState(null); // {key, diameter} while dragging the handle
// What follows the pointer while a plant is dragged: {icon, size, x, y} in px.
const [avatar, setAvatar] = useState(null);
// Stepping stones, labels and rows: saved with the plants, in the same request.
const [features, setFeatures] = useState(initialFeatures);
const lastSavedFeatures = useRef(initialFeatures);
const featureCount = useRef(0);
// A line's end or an area's corner being dragged, and where it is so far.
const endDragged = useRef(null);
// The bed's edge being dragged, and the size it would be so far.
const bedDrag = useRef(null);
const [liveBed, setLiveBed] = useState(null);
const [liveEnd, setLiveEnd] = useState(null);
// The size last given to a plant of each planting, by planting id, so the
// next one taken off its chip matches: shrink a chive and the next is as small.
const nextSizes = useRef({});
const everyPlant = useMemo(() => allPlants(plantings), [plantings]);
const onGrid = useMemo(() => everyPlant.filter(({plant}) => isPlaced(plant)), [everyPlant]);
const colours = useMemo(() => ringColours(plantings), [plantings]);
// The planting being pointed at, on the bed or in the sidebar, whose plants
// are picked out and the rest faded.
const [hovered, setHovered] = useState(null);
// How wide a cell is on screen, to know which plants have room for a name.
const [cellPx, setCellPx] = useState(0);
useEffect(() => {
const soil = soilRef.current;
if (!soil || typeof ResizeObserver === 'undefined') return undefined;
const measure = () => setCellPx(soil.getBoundingClientRect().width / garden.grid_columns);
measure();
const observer = new ResizeObserver(measure);
observer.observe(soil);
return () => observer.disconnect();
}, [garden.grid_columns]);
function placementsOf(list) {
return allPlants(list)
.filter(({plant}) => isPlaced(plant))
.map(({plant, planting}) => {
const where = {bed_x: plant.bed_x, bed_y: plant.bed_y, diameter: plant.diameter ?? null};
return plant.id === null || plant.id === undefined
? {planting_id: planting.id, ...where}
: {plant_id: plant.id, ...where};
});
}
// composted: ids of plants to delete outright, rather than just take off the
// bed; the server needs telling, since a missing plant otherwise means lifted.
// nextFeatures: the stones, labels and rows to save along with the plants.
async function save(unfitted, {composted = [], nextFeatures = features} = {}) {
const next = fitPlants(unfitted, garden);
setPlantings(next);
setFeatures(nextFeatures);
setSaving(true);
setMessage(null);
const {ok, data} = await patchJson(saveUrl, {placements: placementsOf(next), composted, features: nextFeatures});
setSaving(false);
if (ok && data) {
const saved = fitPlants(withKeys(data.plantings), garden);
lastSaved.current = saved;
setPlantings(saved);
lastSavedFeatures.current = data.features || [];
setFeatures(lastSavedFeatures.current);
} else {
setPlantings(lastSaved.current);
setFeatures(lastSavedFeatures.current);
const reason = firstError(data);
setMessage(reason
? `${reason[0].toUpperCase()}${reason.slice(1)}. The bed has been put back as it was.`
: 'That change could not be saved. The bed has been put back as it was.');
}
}
function withPlant(key, changes) {
return plantings.map((planting) => ({
...planting,
plants: planting.plants.map((plant) => (plant.key === key ? {...plant, ...changes} : plant)),
}));
}
// Where on the bed the pointer let go, in grid cells.
function dropPosition(event) {
const rect = soilRef.current.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * garden.grid_columns;
const y = ((event.clientY - rect.top) / rect.height) * garden.grid_rows;
return {
x: Number(clamp(x, 0, garden.grid_columns).toFixed(3)),
y: Number(clamp(y, 0, garden.grid_rows).toFixed(3)),
};
}
// What the next plant off this planting's chip should be: the size last set
// on one of its plants here, or after a reload its newest plant that has a
// size of its own. Null means follow the crop's default.
function sizeForNext(planting) {
const remembered = nextSizes.current[planting.id];
if (remembered !== undefined) return remembered;
const sized = planting.plants
.filter((plant) => plant.id !== null && plant.id !== undefined && plant.diameter !== null && plant.diameter !== undefined)
.sort((a, b) => b.id - a.id)[0];
return sized ? sized.diameter : null;
}
function moveTo(key, x, y) {
save(withPlant(key, {bed_x: x, bed_y: y}));
}
// Pulling one off a planting's stack. If the planting has a plant that isn't
// on the bed, that one goes down; otherwise this is one more plant than it
// had, and the server creates it.
function placeFromStack(plantingId, x, y) {
const planting = plantings.find((candidate) => candidate.id === plantingId);
const size = sizeForNext(planting);
const spare = planting.plants.find((plant) => !isPlaced(plant));
if (spare) {
save(withPlant(spare.key, {bed_x: x, bed_y: y, diameter: size ?? spare.diameter ?? null}));
return;
}
newPlants.current += 1;
const fresh = {id: null, key: `new-${newPlants.current}`, bed_x: x, bed_y: y, diameter: size};
save(plantings.map((candidate) => (candidate.id === plantingId
? {...candidate, plants: [...candidate.plants, fresh]}
: candidate)));
}
// Off the bed and back on its stack, rather than deleted: the planting still
// has that plant, it just isn't mapped.
function takeOffGrid(key) {
save(withPlant(key, {bed_x: null, bed_y: null}));
}
// A new planting comes back from the form as a garden card, so re-read the
// layout to get it as chips, keeping whatever is already on the bed.
async function planted(_card, crop) {
setPlanting(false);
const {ok, data} = await getJson(layoutUrl);
if (ok && data) {
const fresh = fitPlants(withKeys(data.plantings), garden);
lastSaved.current = fresh;
setPlantings(fresh);
lastSavedFeatures.current = data.features || [];
setFeatures(lastSavedFeatures.current);
setMessage(null);
setNotice(`Planted ${crop ? crop.name : 'something'}. Drag it onto the bed.`);
} else {
setMessage('Planted, but the layout could not be refreshed. Reload the page to see it.');
}
}
// Lifts every plant back onto its stack. The plants themselves stay, so the
// plantings keep their counts; only the arrangement goes, hence the check.
function clearBed() {
// eslint-disable-next-line no-alert
if (!window.confirm(`Take all ${onGrid.length} plants off the bed? Their positions will be lost. `
+ 'Stones, lines, netting and the rest stay where they are.')) return;
save(plantings.map((planting) => ({
...planting,
plants: planting.plants.map((plant) => ({...plant, bed_x: null, bed_y: null})),
})));
}
// How far out the placed plants reach, edges and all, so the bed can't
// shrink out from under one.
function reach() {
const plantsReach = onGrid.reduce((far, {plant, planting}) => {
const radius = diameterOf(plant, planting) / 2;
return {
columns: Math.max(far.columns, plant.bed_x + radius), rows: Math.max(far.rows, plant.bed_y + radius),
};
}, {columns: 0, rows: 0});
return features.reduce((far, feature) => {
const margin = featureSize(feature) / 2;
return {
columns: Math.max(far.columns, feature.x + margin, feature.x2 ?? 0),
rows: Math.max(far.rows, feature.y + margin, feature.y2 ?? 0),
};
}, plantsReach);
}
// Redraws straight away, and saves once changes pause, so each keystroke
// isn't a request. A shrink that would strand something on the bed is refused
// up front, where it can say which way it's blocked, rather than after a
// round trip.
function setBedSize(columns, rows) {
const far = reach();
let blocked = null;
if (columns < far.columns) blocked = 'columns';
else if (rows < far.rows) blocked = 'rows';
if (blocked) {
setMessage(`The bed can't be that small: there are things out to ${blocked === 'columns' ? 'column' : 'row'} ${Math.ceil(far[blocked])}. Move them in first.`);
return;
}
setMessage(null);
const next = {...garden, grid_columns: columns, grid_rows: rows};
setGarden(next);
clearTimeout(resizeTimer.current);
resizeTimer.current = setTimeout(() => saveSize(next), 600);
}
// From the Columns and Rows boxes.
function resize(dimension, value) {
const size = Math.round(Number(value));
if (!Number.isFinite(size) || size < 1 || size > maxGridSize) return;
setBedSize(dimension === 'columns' ? size : garden.grid_columns, dimension === 'rows' ? size : garden.grid_rows);
}
// Dragging the bed's right edge, bottom edge or corner. The pointer is
// measured in cells as they were when the drag began, and the bed only
// changes when it's let go: it's sized to fit the window, so redrawing it
// mid-drag would shrink the cells under the pointer. Until then a dashed
// outline shows the new size.
function startBedDrag(event, edge) {
event.preventDefault();
event.stopPropagation();
const rect = soilRef.current.getBoundingClientRect();
const cell = rect.width / garden.grid_columns;
bedDrag.current = {edge, left: rect.left, top: rect.top, cell};
event.currentTarget.setPointerCapture(event.pointerId);
setLiveBed({columns: garden.grid_columns, rows: garden.grid_rows, cell});
}
function moveBedDrag(event) {
const current = bedDrag.current;
if (!current) return;
const cells = (distance) => clamp(Math.round(distance / current.cell), 1, maxGridSize);
setLiveBed({
columns: current.edge === 'bottom' ? garden.grid_columns : cells(event.clientX - current.left),
rows: current.edge === 'right' ? garden.grid_rows : cells(event.clientY - current.top),
cell: current.cell,
});
}
function finishBedDrag() {
const current = bedDrag.current;
bedDrag.current = null;
if (current && liveBed && (liveBed.columns !== garden.grid_columns || liveBed.rows !== garden.grid_rows)) {
setBedSize(liveBed.columns, liveBed.rows);
}
setLiveBed(null);
}
function bedEdgeProps(edge) {
return {
onPointerDown: (event) => startBedDrag(event, edge),
onPointerMove: moveBedDrag,
onPointerUp: finishBedDrag,
onPointerCancel: finishBedDrag,
};
}
async function saveSize(next) {
setSaving(true);
const {ok, data} = await patchJson(next.url, {garden: {grid_columns: next.grid_columns, grid_rows: next.grid_rows}});
setSaving(false);
if (ok) {
savedSize.current = {columns: next.grid_columns, rows: next.grid_rows};
} else {
setGarden((current) => ({
...current, grid_columns: savedSize.current.columns, grid_rows: savedSize.current.rows,
}));
setMessage(`${firstError(data) || 'The new size could not be saved'}. The bed is back to its old size.`);
}
}
// A planting's chip dropped in the bin: compost one of its plants that isn't
// on the bed. Placed ones are left alone; to compost one of those, drag it.
function compostSpare(plantingId, event) {
const planting = plantings.find((candidate) => candidate.id === plantingId);
if (!planting) return;
const spare = planting.plants.find((plant) => !isPlaced(plant));
if (!spare) {
setMessage(`Every ${planting.crop_name} is on the bed. Drag the one you want to compost into the bin.`);
return;
}
compost(spare.key, event);
}
// Starts a plant's flight from the pointer to the middle of the bin's icon.
function throwInBin(event, planting) {
if (prefersReducedMotion() || !binIcon.current) return;
const bin = binIcon.current.getBoundingClientRect();
ghostCount.current += 1;
setGhosts((current) => [...current, {
id: ghostCount.current,
icon: planting.icon_url,
from: {x: event.clientX, y: event.clientY},
to: {x: bin.left + (bin.width / 2), y: bin.top + (bin.height / 2)},
}]);
}
// The plant has landed: clear its copy away and let the bin swallow.
function landed(id) {
setGhosts((current) => current.filter((ghost) => ghost.id !== id));
setGulping(true);
clearTimeout(gulpTimer.current);
gulpTimer.current = setTimeout(() => setGulping(false), 450);
}
// Into the compost: that plant is gone, and its planting has one fewer. A
// plant dragged out of a stack but not yet saved just disappears.
function compost(key, event) {
const entry = everyPlant.find(({plant}) => plant.key === key);
if (!entry) return;
throwInBin(event, entry.planting);
setNotice(`Composted a ${entry.planting.crop_name}.`);
save(
plantings.map((planting) => ({...planting, plants: planting.plants.filter((plant) => plant.key !== key)})),
{composted: entry.plant.id === null || entry.plant.id === undefined ? [] : [entry.plant.id]},
);
}
// The handle on a plant's edge: drag it out to grow the plant, in to shrink
// it. Uses pointer events rather than drag-and-drop, which moves the plant.
function startResize(event, plant) {
event.preventDefault();
event.stopPropagation();
const rect = soilRef.current.getBoundingClientRect();
resizing.current = {
key: plant.key,
cellPx: rect.width / garden.grid_columns,
centre: {
x: rect.left + ((plant.bed_x / garden.grid_columns) * rect.width),
y: rect.top + ((plant.bed_y / garden.grid_rows) * rect.height),
},
};
event.currentTarget.setPointerCapture(event.pointerId);
}
function resizeTo(event) {
const current = resizing.current;
if (!current) return;
const distance = Math.hypot(event.clientX - current.centre.x, event.clientY - current.centre.y);
// In steps of a twentieth of a cell, so sizes come out as tidy numbers.
// No bigger than the bed, which it couldn't then fit on.
const largest = Math.min(maxDiameter, garden.grid_columns, garden.grid_rows);
const diameter = clamp(Math.round((2 * distance / current.cellPx) * 20) / 20, 0.25, largest);
setLiveSize({key: current.key, diameter});
}
function endResize() {
const current = resizing.current;
resizing.current = null;
if (liveSize && current && liveSize.key === current.key) {
const resized = everyPlant.find(({plant}) => plant.key === current.key);
if (resized) nextSizes.current[resized.planting.id] = liveSize.diameter;
save(withPlant(current.key, {diameter: liveSize.diameter}));
}
setLiveSize(null);
}
// The garden features: stones, sprinklers, labels, rows, netting and the rest.
// Each change saves the whole arrangement, as a plant does.
function saveFeatures(nextFeatures) {
save(plantings, {nextFeatures});
}
function newFeatureId() {
featureCount.current += 1;
return `f${Date.now().toString(36)}${featureCount.current}`;
}
function onBed(value, size, margin = 0) {
return round3(clamp(value, margin, size - margin));
}
function askForText(question, current = '') {
// eslint-disable-next-line no-alert
const answer = window.prompt(question, current);
return answer === null ? null : answer.trim().slice(0, 40);
}
function addFeature(kind, x, y) {
const type = FEATURE_TYPES[kind];
if (!type) return;
const {grid_columns: columns, grid_rows: rows} = garden;
const id = newFeatureId();
if (type.shape === 'point') {
const margin = type.size / 2;
saveFeatures([...features, {id, kind, x: onBed(x, columns, margin), y: onBed(y, rows, margin)}]);
} else if (type.shape === 'label') {
const text = askForText('What should the label say?');
if (text) saveFeatures([...features, {id, kind, x: onBed(x, columns), y: onBed(y, rows), text}]);
} else {
// A line two cells long, across the bed, or an area two cells square;
// drag its ends or corners to reshape it.
const halfX = Math.min(1, columns / 2);
const halfY = type.shape === 'area' ? Math.min(1, rows / 2) : 0;
const middleX = onBed(x, columns, halfX);
const middleY = onBed(y, rows, halfY);
saveFeatures([...features, {
id, kind,
x: round3(middleX - halfX), y: round3(middleY - halfY), x2: round3(middleX + halfX), y2: round3(middleY + halfY),
}]);
}
}
// Moves a point or label to (x, y). A line or area moves by its middle,
// keeping its shape, and all of it on the bed.
function moveFeature(id, x, y) {
const {grid_columns: columns, grid_rows: rows} = garden;
saveFeatures(features.map((feature) => {
if (feature.id !== id) return feature;
if (!isSpan(feature)) {
const margin = featureSize(feature) / 2;
return {...feature, x: onBed(x, columns, margin), y: onBed(y, rows, margin)};
}
const halfX = (feature.x2 - feature.x) / 2;
const halfY = (feature.y2 - feature.y) / 2;
const middleX = clamp(x, Math.abs(halfX), columns - Math.abs(halfX));
const middleY = clamp(y, Math.abs(halfY), rows - Math.abs(halfY));
return {
...feature,
x: round3(middleX - halfX), y: round3(middleY - halfY), x2: round3(middleX + halfX), y2: round3(middleY + halfY),
};
}));
}
function removeFeature(id, event) {
const feature = features.find((candidate) => candidate.id === id);
if (!feature) return;
if (featureIcons[feature.kind]) throwInBin(event, {icon_url: featureIcons[feature.kind]});
setNotice(`Took the ${featureName(feature.kind)} off the bed.`);
saveFeatures(features.filter((candidate) => candidate.id !== id));
}
// A label's text, or the name of a line or area, which they can do without.
function rename(feature) {
let question = `What would you like to call this ${featureName(feature.kind)}? (Leave it empty for no name.)`;
if (feature.kind === 'row') question = 'What is growing in this row? (Leave it empty for no name.)';
if (feature.kind === 'label') question = 'What should the label say?';
const text = askForText(question, feature.text || '');
if (text === null || (feature.kind === 'label' && !text)) return;
saveFeatures(features.map((candidate) => (candidate.id === feature.id ? {...candidate, text} : candidate)));
}
// Dragging a line's end or an area's corner, with pointer events like the
// plant resize handle.
function startEndDrag(event, row, end) {
event.preventDefault();
event.stopPropagation();
endDragged.current = {id: row.id, end};
event.currentTarget.setPointerCapture(event.pointerId);
}
function moveEndDrag(event) {
if (!endDragged.current) return;
const rect = soilRef.current.getBoundingClientRect();
const x = onBed(((event.clientX - rect.left) / rect.width) * garden.grid_columns, garden.grid_columns);
const y = onBed(((event.clientY - rect.top) / rect.height) * garden.grid_rows, garden.grid_rows);
setLiveEnd({...endDragged.current, x, y});
}
function finishEndDrag() {
const current = endDragged.current;
endDragged.current = null;
if (current && liveEnd && liveEnd.id === current.id) {
saveFeatures(features.map((feature) => (feature.id === current.id
? withEnd(feature, current.end, liveEnd.x, liveEnd.y)
: feature)));
}
setLiveEnd(null);
}
// A line or area as drawn right now: with the end or corner being dragged
// where it has got to.
function asDrawn(row) {
return liveEnd && liveEnd.id === row.id ? withEnd(row, liveEnd.end, liveEnd.x, liveEnd.y) : row;
}
function dragData(event) {
const [kind, value] = (event.dataTransfer.getData('text/plain') || '').split(':');
return {kind, value};
}
// look: the crop icon and diameter in cells, for the circle that follows the
// pointer while it's dragged.
function dragProps(payload, look) {
if (!editable) return {};
return {
draggable: true,
onDragStart: (event) => {
// Pulling on a plant's resize handle resizes it; it shouldn't also
// start dragging the plant it belongs to.
if (resizing.current || endDragged.current || bedDrag.current) {
event.preventDefault();
return;
}
event.dataTransfer.setData('text/plain', payload);
event.dataTransfer.effectAllowed = 'move';
if (invisibleDragImage) event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
const cell = soilRef.current.getBoundingClientRect().width / garden.grid_columns;
setAvatar({
icon: look.icon, text: look.text, size: Math.max(20, look.diameter * cell), x: event.clientX, y: event.clientY,
});
setDragging(payload);
},
onDragEnd: endDrag,
};
}
function endDrag() {
setDragging(null);
setAvatar(null);
}
// Follow the pointer anywhere on the page while dragging, and tidy up when the
// drag ends however it ends. The drag's own end event isn't enough: a plant
// dropped in the compost is gone from the page before that event would fire.
useEffect(() => {
if (!avatar) return undefined;
let frame = null;
const follow = (event) => {
const {clientX: x, clientY: y} = event;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = null;
setAvatar((current) => (current ? {...current, x, y} : current));
});
};
document.addEventListener('dragover', follow);
document.addEventListener('drop', endDrag, true);
document.addEventListener('dragend', endDrag, true);
return () => {
if (frame) cancelAnimationFrame(frame);
document.removeEventListener('dragover', follow);
document.removeEventListener('drop', endDrag, true);
document.removeEventListener('dragend', endDrag, true);
};
}, [avatar !== null]); // eslint-disable-line react-hooks/exhaustive-deps
function dropOnBed(event) {
event.preventDefault();
const {kind, value} = dragData(event);
const {x, y} = dropPosition(event);
if (kind === 'stack') placeFromStack(Number(value), x, y);
if (kind === 'plant') moveTo(value, x, y);
if (kind === 'new-feature') addFeature(value, x, y);
if (kind === 'feature') moveFeature(value, x, y);
}
// The page gives the bin a slot of its own, under "About this garden", so it
// stays in view; it's drawn there through a portal so it keeps this state.
const [compostSlot, setCompostSlot] = useState(null);
useEffect(() => setCompostSlot(document.getElementById('garden-layout-compost')), []);
const compostBin = (
event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
// Without a slot on the page the bin sits inside the sidebar, whose own
// drop lifts a plant back onto its stack; this one must not do both.
event.stopPropagation();
const {kind, value} = dragData(event);
if (kind === 'plant') compost(value, event);
if (kind === 'stack') compostSpare(Number(value), event);
if (kind === 'feature') removeFeature(value, event);
}}
>
Compost bin
Drop a plant or a crop here to remove one from its planting.
);
// A position in cells as a percentage of the bed, for placing things on it.
const acrossBed = (cells) => `${(cells / garden.grid_columns) * 100}%`;
const downBed = (cells) => `${(cells / garden.grid_rows) * 100}%`;
const cells = [];
for (let i = 0; i < garden.grid_rows * garden.grid_columns; i += 1) {
cells.push();
}
return (
{message &&
{message}
}
{notice && !message &&
{notice}
}
{resizable && (
×
)}
{editable && onGrid.length > 0 && (
)}
{/* Everything besides plants, as a row of tiles to drag onto the bed,
grouped by what it's for, each named when pointed at. */}
{editable && (
{FEATURE_GROUPS.map((group) => (
{group.name}
{group.kinds.map((kind) => {
const type = FEATURE_TYPES[kind];
const what = kind === 'netting' ? 'netting' : `a ${featureName(kind)}`;
return (
event.preventDefault() : undefined}
onDrop={editable ? dropOnBed : undefined}
>
{/* The bed's edges, on the frame, to drag it bigger or smaller. */}
{resizable && (
<>
>
)}
{liveBed && (
{liveBed.columns} × {liveBed.rows}
)}
{cells}
{/* Lines, then points, under the plants. */}
{features.filter((feature) => shapeOf(feature) === 'point').map((point) => (
{/* A stone and a stake are drawn; the rest show their icon. */}
{!['stone', 'stake'].includes(point.kind) && featureIcons[point.kind] && (
)}
))}
{/* Biggest first, so a small plant is drawn over a big one it sits beside. */}
{[...onGrid]
.sort((a, b) => diameterOf(b.plant, b.planting) - diameterOf(a.plant, a.planting))
.map(({plant, planting}) => {
const diameter = liveSize && liveSize.key === plant.key
? liveSize.diameter
: diameterOf(plant, planting);
return (
{/* Not a link: a plant on the bed is something to pick up and
move, so a click shouldn't take you away to its planting. */}
setHovered(planting.id)}
onMouseLeave={() => setHovered(null)}
>
{/* Its name, when the plant is big enough on screen to carry one. */}
{diameter * cellPx >= 44 && (
{planting.crop_name}
)}
{editable && (
startResize(event, plant)}
onPointerMove={resizeTo}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
)}
);
})}
{/* Labels, over the plants, like markers stuck in the soil. */}
{features.filter((feature) => feature.kind === 'label').map((label) => (
))}
{/* Netting, over the plants it covers. */}
{/* The handles of lines and areas, on top of everything: the middle to
move it and show its name, the ends or corners to reshape it. */}
{features.filter(isSpan).map(asDrawn).map((span) => {
const name = FEATURE_TYPES[span.kind].name;
const corner = shapeOf(span) === 'area' ? 'corner' : 'end';
return (
{(editable || span.text) && (