Projects/3BIT/winter-semester/IIS/xnecasr00/resources/js/vineyard/map-edit.js
2026-04-14 19:28:46 +02:00

1171 lines
39 KiB
JavaScript

import $ from 'jquery';
import axios from 'axios';
const CELL_SIZE = 80;
const CELL_GAP = 16;
const GRID_UNIT = CELL_SIZE + CELL_GAP;
const MIN_ZOOM = 0.05;
const MAX_ZOOM = 3;
const ZOOM_FACTOR = 0.1;
const SCROLL_SENSITIVITY = 0.6;
function parseData(elementId, fallback) {
const el = document.getElementById(elementId);
if (!el) {
return fallback;
}
try {
return JSON.parse(el.textContent ?? 'null') ?? fallback;
} catch (error) {
console.error(`Failed to parse payload for ${elementId}`, error);
return fallback;
}
}
function computeContentDimensions(rows) {
const maxX = Math.max(0, ...rows.map((row) => row.location.x));
const maxY = Math.max(0, ...rows.map((row) => row.location.y));
const width = Math.max(1, maxX + 1) * (CELL_SIZE + CELL_GAP);
const height = Math.max(1, maxY + 1) * (CELL_SIZE + CELL_GAP);
return { width, height };
}
function applyStatusClass(element, status) {
const targetStatus = status ?? 'unknown';
element.classList.forEach((name) => {
if (name.startsWith('status-')) {
element.classList.remove(name);
}
});
element.classList.add(`status-${targetStatus}`);
}
function createCell(row) {
const cell = document.createElement('div');
cell.classList.add('map-cell');
cell.dataset.rowId = String(row.id);
cell.style.left = `${row.location.x * (CELL_SIZE + CELL_GAP)}px`;
cell.style.top = `${row.location.y * (CELL_SIZE + CELL_GAP)}px`;
cell.style.width = `${CELL_SIZE}px`;
cell.style.height = `${CELL_SIZE}px`;
applyStatusClass(cell, row.status);
const countBadge = document.createElement('div');
countBadge.classList.add('map-cell-count');
countBadge.dataset.role = 'vine-count';
countBadge.textContent = String(row.vine_count ?? 0);
const varietyLabel = document.createElement('div');
varietyLabel.classList.add('map-cell-variety');
varietyLabel.dataset.role = 'variety-label';
varietyLabel.textContent = row.variety?.label ?? 'Unassigned';
cell.appendChild(countBadge);
cell.appendChild(varietyLabel);
return cell;
}
export default function initMapEdit() {
const $root = $('[data-page="vineyard-map-edit"]');
if (!$root.length) {
return;
}
const rows = parseData('vineyard-edit-rows', []);
const routes = parseData('vineyard-edit-routes', {});
const mapViewport = $root.find('[data-role="map-container"]')[0];
if (!mapViewport) {
return;
}
let currentRows = [...rows];
const rowElements = new Map();
const locationIndex = new Map();
const coordinateKey = (x, y) => `${x}:${y}`;
const parseCoordinateKey = (key) => {
const [x, y] = key.split(':').map((value) => parseInt(value, 10));
return { x, y };
};
let selectedRow = null;
let relocationTarget = null;
let currentMode = 'edit';
const removeSelection = new Set();
const addSelection = new Set();
const addMarkers = new Map();
let isSelectingArea = false;
let selectionBox = null;
let selectionStartPoint = null;
let selectionMerge = false;
let selectionMode = null;
let selectionMoved = false;
let suppressNextClick = false;
mapViewport.innerHTML = '';
const mapContent = document.createElement('div');
mapContent.classList.add('vineyard-map-grid');
mapViewport.appendChild(mapContent);
const selectionLayer = document.createElement('div');
selectionLayer.classList.add('map-selection-layer');
mapViewport.appendChild(selectionLayer);
const pendingMarker = document.createElement('div');
pendingMarker.classList.add('pending-cell');
pendingMarker.style.width = `${CELL_SIZE}px`;
pendingMarker.style.height = `${CELL_SIZE}px`;
mapContent.appendChild(pendingMarker);
let contentDimensions = computeContentDimensions(currentRows);
const viewportState = {
scale: 1,
translateX: 0,
translateY: 0,
};
let viewportAdjusted = false;
const markViewportAdjusted = () => {
viewportAdjusted = true;
};
const clampTransform = () => {
const viewportWidth = mapViewport.clientWidth;
const viewportHeight = mapViewport.clientHeight;
const scaledWidth = contentDimensions.width * viewportState.scale;
const scaledHeight = contentDimensions.height * viewportState.scale;
if (scaledWidth <= viewportWidth) {
viewportState.translateX = (viewportWidth - scaledWidth) / 2;
} else {
const minX = viewportWidth - scaledWidth;
viewportState.translateX = Math.min(0, Math.max(minX, viewportState.translateX));
}
if (scaledHeight <= viewportHeight) {
viewportState.translateY = (viewportHeight - scaledHeight) / 2;
} else {
const minY = viewportHeight - scaledHeight;
viewportState.translateY = Math.min(0, Math.max(minY, viewportState.translateY));
}
};
const applyTransform = () => {
clampTransform();
mapContent.style.transform = `translate(${viewportState.translateX}px, ${viewportState.translateY}px) scale(${viewportState.scale})`;
};
const fitContentToViewport = ({ force = false } = {}) => {
if (viewportAdjusted && !force) {
applyTransform();
return;
}
if (!contentDimensions.width || !contentDimensions.height) {
viewportState.scale = 1;
viewportState.translateX = 0;
viewportState.translateY = 0;
applyTransform();
return;
}
const viewportWidth = mapViewport.clientWidth || 1;
const viewportHeight = mapViewport.clientHeight || 1;
const scaleX = viewportWidth / contentDimensions.width;
const scaleY = viewportHeight / contentDimensions.height;
const fittedScale = Math.min(1, Math.min(scaleX, scaleY));
const scale = Math.min(MAX_ZOOM, Math.max(fittedScale, Math.min(MIN_ZOOM, fittedScale)));
viewportState.scale = scale;
viewportState.translateX = (viewportWidth - contentDimensions.width * viewportState.scale) / 2;
viewportState.translateY = (viewportHeight - contentDimensions.height * viewportState.scale) / 2;
applyTransform();
};
const mapPointFromViewport = (x, y) => ({
x: (x - viewportState.translateX) / viewportState.scale,
y: (y - viewportState.translateY) / viewportState.scale,
});
const mapPointFromEvent = (event) => {
const rect = mapViewport.getBoundingClientRect();
const viewportX = event.clientX - rect.left;
const viewportY = event.clientY - rect.top;
return {
viewport: { x: viewportX, y: viewportY, width: rect.width, height: rect.height },
map: mapPointFromViewport(viewportX, viewportY),
};
};
const mapRectFromClientRect = (rect) => {
const viewportRect = mapViewport.getBoundingClientRect();
const left = rect.left - viewportRect.left;
const top = rect.top - viewportRect.top;
const right = rect.right - viewportRect.left;
const bottom = rect.bottom - viewportRect.top;
const topLeft = mapPointFromViewport(left, top);
const bottomRight = mapPointFromViewport(right, bottom);
return {
left: Math.min(topLeft.x, bottomRight.x),
top: Math.min(topLeft.y, bottomRight.y),
right: Math.max(topLeft.x, bottomRight.x),
bottom: Math.max(topLeft.y, bottomRight.y),
};
};
const updateContentSize = ({ preferFit = false } = {}) => {
contentDimensions = computeContentDimensions(currentRows);
mapContent.style.width = `${contentDimensions.width}px`;
mapContent.style.height = `${contentDimensions.height}px`;
if (!viewportAdjusted || preferFit) {
fitContentToViewport({ force: preferFit });
} else {
applyTransform();
}
};
const mountRow = (row) => {
const cell = createCell(row);
mapContent.appendChild(cell);
rowElements.set(Number(row.id), cell);
locationIndex.set(coordinateKey(row.location.x, row.location.y), Number(row.id));
};
currentRows.forEach(mountRow);
updateContentSize({ preferFit: true });
const refreshCellStates = () => {
rowElements.forEach((element, rowId) => {
const isSelected = currentMode === 'edit' && selectedRow && Number(selectedRow.id) === rowId;
const isMarkedForRemoval = removeSelection.has(rowId) && currentMode === 'remove';
element.classList.toggle('selected', Boolean(isSelected));
element.classList.toggle('remove-selected', Boolean(isMarkedForRemoval));
});
};
const $rowForm = $root.find('[data-role="row-form"]');
const $deleteButton = $rowForm.find('[data-control="delete-row"]');
const $saveRowButton = $rowForm.find('button[type="submit"]');
const $relocationTarget = $root.find('[data-role="relocation-target"]');
const $removeCommit = $root.find('[data-control="commit-remove-selection"]');
const $removeCount = $root.find('[data-role="remove-selection-count"]');
const $removeClear = $root.find('[data-control="clear-remove-selection"]');
const $addCommit = $root.find('[data-control="commit-add-selection"]');
const $addClear = $root.find('[data-control="clear-add-selection"]');
const $addCount = $root.find('[data-role="add-selection-count"]');
const $addVineCount = $root.find('[name="add_vine_count"]');
const $clearRelocation = $root.find('[data-control="clear-relocation"]');
const $modeSwitch = $root.find('[data-role="mode-switch"]');
const $modeButtons = $modeSwitch.find('[data-mode]');
const $modePanels = $root.find('[data-mode-panel]');
const $feedback = $root.find('[data-role="map-feedback"]');
const setFeedback = (message, tone = 'info') => {
if (!$feedback.length) {
return;
}
$feedback.removeClass('feedback-info feedback-success feedback-error');
if (!message) {
$feedback.text('');
return;
}
$feedback.addClass(`feedback-${tone}`);
$feedback.text(message);
};
const updateActionAvailability = () => {
const removeActive = currentMode === 'remove';
const addActive = currentMode === 'add';
$clearRelocation.prop('disabled', !relocationTarget);
$removeCommit.prop('disabled', !(removeActive && removeSelection.size > 0));
$removeClear.prop('disabled', removeSelection.size === 0);
$addCommit.prop('disabled', !(addActive && addSelection.size > 0));
$addClear.prop('disabled', addSelection.size === 0);
if (currentMode !== 'edit') {
$saveRowButton.prop('disabled', true);
$deleteButton.prop('disabled', true);
}
};
const updatePendingMarker = () => {
const target = currentMode === 'edit' ? relocationTarget : null;
if (target) {
pendingMarker.style.left = `${target.x * GRID_UNIT}px`;
pendingMarker.style.top = `${target.y * GRID_UNIT}px`;
pendingMarker.style.display = 'block';
} else {
pendingMarker.style.display = 'none';
}
};
const populateRowForm = (row) => {
$rowForm.find('[name="row_id"]').val(row?.id ?? '');
$rowForm.find('[name="location"]').val(row ? `${row.location.x},${row.location.y}` : '');
$rowForm.find('[name="vine_count"]').val(row?.vine_count ?? '');
$deleteButton.prop('disabled', !row);
$saveRowButton.prop('disabled', !row);
updateActionAvailability();
};
const stageRelocation = (coords) => {
relocationTarget = coords;
if (coords) {
$relocationTarget.text(`${coords.x},${coords.y}`);
} else {
$relocationTarget.text('None');
}
updatePendingMarker();
updateActionAvailability();
};
const updateAddUi = () => {
$addCount.text(String(addSelection.size));
updateActionAvailability();
};
const ensureAddMarker = (coords) => {
const key = coordinateKey(coords.x, coords.y);
if (addMarkers.has(key)) {
return addMarkers.get(key);
}
const marker = document.createElement('div');
marker.classList.add('add-marker');
marker.style.left = `${coords.x * GRID_UNIT}px`;
marker.style.top = `${coords.y * GRID_UNIT}px`;
marker.style.width = `${CELL_SIZE}px`;
marker.style.height = `${CELL_SIZE}px`;
mapContent.appendChild(marker);
addMarkers.set(key, marker);
return marker;
};
const stageAddTarget = (coords) => {
const key = coordinateKey(coords.x, coords.y);
if (addSelection.has(key) || locationIndex.has(key)) {
return;
}
addSelection.add(key);
ensureAddMarker(coords);
};
const removeAddTargetByKey = (key) => {
if (!addSelection.has(key)) {
return;
}
addSelection.delete(key);
const marker = addMarkers.get(key);
if (marker) {
marker.remove();
addMarkers.delete(key);
}
};
const clearAddTargets = ({ silent = false } = {}) => {
if (addSelection.size === 0) {
if (!silent) {
updateAddUi();
}
return;
}
addSelection.forEach((key) => {
const marker = addMarkers.get(key);
if (marker) {
marker.remove();
}
});
addSelection.clear();
addMarkers.clear();
if (!silent) {
updateAddUi();
}
};
const cancelAreaSelection = () => {
if (selectionBox) {
selectionBox.remove();
}
selectionBox = null;
selectionStartPoint = null;
isSelectingArea = false;
selectionMerge = false;
selectionMode = null;
selectionMoved = false;
};
const beginAreaSelection = (event) => {
if (event.button !== 0) {
return;
}
if (!(currentMode === 'remove' || currentMode === 'add')) {
return;
}
if (event.target.closest('[data-row-id]')) {
return;
}
cancelAreaSelection();
isSelectingArea = true;
selectionMode = currentMode;
selectionMerge = event.shiftKey;
selectionMoved = false;
selectionBox = document.createElement('div');
selectionBox.classList.add('selection-box');
selectionLayer.appendChild(selectionBox);
const rect = mapViewport.getBoundingClientRect();
const startX = event.clientX - rect.left;
const startY = event.clientY - rect.top;
selectionStartPoint = { x: startX, y: startY };
Object.assign(selectionBox.style, {
left: `${startX}px`,
top: `${startY}px`,
width: '0px',
height: '0px',
});
event.preventDefault();
};
const updateAreaSelection = (event) => {
if (!isSelectingArea || !selectionBox || !selectionStartPoint) {
return;
}
const rect = mapViewport.getBoundingClientRect();
const currentX = event.clientX - rect.left;
const currentY = event.clientY - rect.top;
const left = Math.min(selectionStartPoint.x, currentX);
const top = Math.min(selectionStartPoint.y, currentY);
const width = Math.abs(currentX - selectionStartPoint.x);
const height = Math.abs(currentY - selectionStartPoint.y);
if (!selectionMoved && (width > 3 || height > 3)) {
selectionMoved = true;
}
Object.assign(selectionBox.style, {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
});
};
const finalizeAreaSelection = () => {
if (!isSelectingArea) {
return;
}
const boxRect = selectionBox?.getBoundingClientRect() ?? null;
if (selectionBox) {
selectionBox.remove();
}
const mode = selectionMode;
const merge = selectionMerge;
const moved = selectionMoved;
selectionBox = null;
selectionStartPoint = null;
isSelectingArea = false;
selectionMode = null;
selectionMerge = false;
selectionMoved = false;
suppressNextClick = moved;
if (!moved || !boxRect || !mode) {
return;
}
suppressNextClick = true;
if (mode === 'remove') {
const intersectingRows = [];
rowElements.forEach((element, rowId) => {
const cellRect = element.getBoundingClientRect();
const fullyContained =
cellRect.left >= boxRect.left &&
cellRect.right <= boxRect.right &&
cellRect.top >= boxRect.top &&
cellRect.bottom <= boxRect.bottom;
if (fullyContained) {
intersectingRows.push(rowId);
}
});
if (!merge) {
removeSelection.clear();
}
intersectingRows.forEach((rowId) => removeSelection.add(rowId));
updateRemoveUi();
const size = removeSelection.size;
setFeedback(
size
? `Marked ${size} row${size === 1 ? '' : 's'} for deletion.`
: 'No rows selected yet. Click rows to mark them for deletion.',
'info'
);
return;
}
if (mode === 'add') {
const bounds = mapRectFromClientRect(boxRect);
const totalColumns = Math.max(1, Math.ceil(contentDimensions.width / GRID_UNIT));
const totalRows = Math.max(1, Math.ceil(contentDimensions.height / GRID_UNIT));
const startX = Math.max(0, Math.min(Math.floor(bounds.left / GRID_UNIT), totalColumns - 1));
const endX = Math.max(startX, Math.min(Math.ceil(bounds.right / GRID_UNIT), totalColumns));
const startY = Math.max(0, Math.min(Math.floor(bounds.top / GRID_UNIT), totalRows - 1));
const endY = Math.max(startY, Math.min(Math.ceil(bounds.bottom / GRID_UNIT), totalRows));
const coords = [];
for (let x = startX; x < endX; x += 1) {
for (let y = startY; y < endY; y += 1) {
const key = coordinateKey(x, y);
if (locationIndex.has(key)) {
continue;
}
const cellLeft = x * GRID_UNIT;
const cellTop = y * GRID_UNIT;
const cellRight = cellLeft + CELL_SIZE;
const cellBottom = cellTop + CELL_SIZE;
const fullyContained =
cellLeft >= bounds.left &&
cellRight <= bounds.right &&
cellTop >= bounds.top &&
cellBottom <= bounds.bottom;
if (fullyContained) {
coords.push({ x, y });
}
}
}
if (!merge) {
clearAddTargets({ silent: true });
}
coords.forEach((coord) => stageAddTarget(coord));
updateAddUi();
const size = addSelection.size;
setFeedback(
size
? `Staged ${size} new row${size === 1 ? '' : 's'} for creation.`
: 'No empty squares selected yet.',
'info'
);
}
};
const handleWheel = (event) => {
if (event.ctrlKey) {
event.preventDefault();
const previousScale = viewportState.scale;
const direction = -event.deltaY;
const scaleDelta = direction > 0 ? 1 + ZOOM_FACTOR : 1 - ZOOM_FACTOR;
const nextScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewportState.scale * scaleDelta));
if (nextScale === previousScale) {
return;
}
const rect = mapViewport.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
const zoomRatio = nextScale / previousScale;
viewportState.translateX = offsetX - (offsetX - viewportState.translateX) * zoomRatio;
viewportState.translateY = offsetY - (offsetY - viewportState.translateY) * zoomRatio;
viewportState.scale = nextScale;
markViewportAdjusted();
applyTransform();
return;
}
event.preventDefault();
if (event.shiftKey) {
viewportState.translateX -= event.deltaY * SCROLL_SENSITIVITY;
viewportState.translateX -= event.deltaX * SCROLL_SENSITIVITY;
} else {
viewportState.translateY -= event.deltaY * SCROLL_SENSITIVITY;
viewportState.translateX -= event.deltaX * SCROLL_SENSITIVITY;
}
markViewportAdjusted();
applyTransform();
};
const updateRemoveUi = () => {
$removeCount.text(String(removeSelection.size));
refreshCellStates();
updateActionAvailability();
};
$clearRelocation.on('click', () => {
if (!relocationTarget) {
return;
}
stageRelocation(null);
setFeedback('Staged move cleared.', 'info');
});
$addClear.on('click', () => {
if (addSelection.size === 0) {
return;
}
clearAddTargets();
setFeedback('Selection cleared.', 'info');
});
$addCommit.on('click', async () => {
if (addSelection.size === 0) {
return;
}
if (!routes.add) {
console.error('Add route not configured');
setFeedback('Create route is not configured.', 'error');
return;
}
const vineInput = parseInt($addVineCount.val(), 10);
const vineCount = Number.isFinite(vineInput) ? Math.max(0, vineInput) : 0;
const selectionKeys = Array.from(addSelection.values());
const entries = selectionKeys.map((key) => {
const coords = parseCoordinateKey(key);
return {
key,
x: coords.x,
y: coords.y,
payload: {
location: `${coords.x},${coords.y}`,
vine_count: vineCount,
},
};
});
const rowsPayload = entries.map((entry) => entry.payload);
if (rowsPayload.length === 0) {
setFeedback('No empty squares selected yet.', 'info');
return;
}
try {
const response = await axios.post(routes.add, { rows: rowsPayload });
const createdIds = Array.isArray(response.data?.created) ? response.data.created : [];
if (!createdIds.length) {
setFeedback('Rows were created but the response did not return their identifiers.', 'error');
return;
}
clearAddTargets({ silent: true });
const processed = Math.min(createdIds.length, entries.length);
for (let index = 0; index < processed; index += 1) {
const rawId = createdIds[index];
const numericId = Number(rawId);
if (!Number.isFinite(numericId) || numericId <= 0) {
continue;
}
const entry = entries[index];
const newRow = {
id: numericId,
location: { x: entry.x, y: entry.y },
vine_count: entry.payload.vine_count,
status: 'inactive',
variety: null,
};
upsertRow(newRow);
locationIndex.set(entry.key, numericId);
const element = createCell(newRow);
mapContent.appendChild(element);
rowElements.set(numericId, element);
}
selectedRow = null;
updateAddUi();
updateContentSize();
refreshCellStates();
const requestedCount = entries.length;
const createdCount = Math.min(createdIds.length, requestedCount);
if (createdCount === requestedCount) {
setFeedback(`Added ${createdCount} new row${createdCount === 1 ? '' : 's'}.`, 'success');
} else {
setFeedback(
`Added ${createdCount} new row${createdCount === 1 ? '' : 's'}. Some selections may require a refresh to appear.`,
'info'
);
}
} catch (error) {
console.error('Failed to add rows', error);
setFeedback('Unable to add the selected rows. Please try again.', 'error');
}
});
const setMode = (mode) => {
if (currentMode === mode) {
return;
}
currentMode = mode;
cancelAreaSelection();
$modeButtons.removeClass('active');
$modeButtons.filter(`[data-mode="${mode}"]`).addClass('active');
$modePanels.removeClass('active');
$modePanels.filter(`[data-mode-panel="${mode}"]`).addClass('active');
if (mode !== 'edit') {
selectedRow = null;
populateRowForm(null);
stageRelocation(null);
}
if (mode !== 'remove') {
removeSelection.clear();
}
if (mode !== 'add') {
clearAddTargets({ silent: true });
}
updateRemoveUi();
updateAddUi();
updatePendingMarker();
switch (mode) {
case 'edit':
setFeedback('Edit mode active. Select a row to adjust head count or click an empty cell to stage a move.', 'info');
break;
case 'add':
setFeedback('Add mode active. Select empty squares to stage new rows, then create them.', 'info');
break;
case 'remove':
setFeedback('Remove mode active. Select rows to delete, then confirm the removal.', 'info');
break;
}
};
const handleModeClick = (event) => {
const mode = $(event.currentTarget).data('mode');
if (typeof mode === 'string') {
setMode(mode);
}
};
$modeButtons.on('click', handleModeClick);
populateRowForm(null);
stageRelocation(null);
updateRemoveUi();
updateAddUi();
currentMode = null;
setMode('edit');
const computeCoordinatesFromEvent = (event) => {
const { viewport, map } = mapPointFromEvent(event);
if (viewport.x < 0 || viewport.y < 0 || viewport.x > viewport.width || viewport.y > viewport.height) {
return null;
}
if (map.x < 0 || map.y < 0) {
return null;
}
const cellX = Math.floor(map.x / GRID_UNIT);
const cellY = Math.floor(map.y / GRID_UNIT);
const innerX = map.x - cellX * GRID_UNIT;
const innerY = map.y - cellY * GRID_UNIT;
if (innerX > CELL_SIZE || innerY > CELL_SIZE) {
return null;
}
return { x: cellX, y: cellY };
};
const findRowById = (rowId) => currentRows.find((row) => Number(row.id) === Number(rowId)) ?? null;
const upsertRow = (row) => {
const index = currentRows.findIndex((existing) => Number(existing.id) === Number(row.id));
if (index >= 0) {
currentRows[index] = row;
} else {
currentRows.push(row);
}
};
const removeRowFromCollections = (rowId) => {
const numericId = Number(rowId);
const row = findRowById(numericId);
currentRows = currentRows.filter((entry) => Number(entry.id) !== numericId);
if (row) {
locationIndex.delete(coordinateKey(row.location.x, row.location.y));
}
rowElements.get(numericId)?.remove();
rowElements.delete(numericId);
removeSelection.delete(numericId);
};
mapViewport.addEventListener('click', (event) => {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
const cell = event.target.closest('[data-row-id]');
if (cell) {
event.stopPropagation();
const rowId = Number(cell.dataset.rowId);
if (!Number.isFinite(rowId)) {
return;
}
if (currentMode === 'remove') {
const multi = event.shiftKey;
if (multi) {
if (removeSelection.has(rowId)) {
removeSelection.delete(rowId);
} else {
removeSelection.add(rowId);
}
} else if (removeSelection.size === 1 && removeSelection.has(rowId)) {
removeSelection.clear();
} else {
removeSelection.clear();
removeSelection.add(rowId);
}
updateRemoveUi();
const size = removeSelection.size;
setFeedback(
size
? `Marked ${size} row${size === 1 ? '' : 's'} for deletion.`
: 'No rows selected yet. Click rows to mark them for deletion.',
'info'
);
return;
}
if (currentMode === 'add') {
setFeedback('Add mode works with empty squares. Click an open grid space to stage a new row.', 'info');
return;
}
selectedRow = findRowById(rowId);
populateRowForm(selectedRow);
stageRelocation(null);
refreshCellStates();
if (selectedRow) {
setFeedback(
`Editing row #${selectedRow.id} at ${selectedRow.location.x},${selectedRow.location.y}.`,
'info'
);
}
return;
}
if (currentMode === 'remove') {
setFeedback('Remove mode is active. Click existing rows to mark them for removal.', 'info');
return;
}
const coords = computeCoordinatesFromEvent(event);
if (!coords) {
setFeedback('Click inside a square on the grid to select a location.', 'info');
return;
}
const locationKey = coordinateKey(coords.x, coords.y);
const existingRowId = locationIndex.get(locationKey);
if (currentMode === 'edit') {
if (existingRowId) {
selectedRow = findRowById(existingRowId);
populateRowForm(selectedRow);
stageRelocation(null);
refreshCellStates();
if (selectedRow) {
setFeedback(
`Editing row #${selectedRow.id} at ${selectedRow.location.x},${selectedRow.location.y}.`,
'info'
);
}
return;
}
if (!selectedRow) {
setFeedback('Select a row first before staging a new location.', 'info');
stageRelocation(null);
return;
}
stageRelocation(coords);
setFeedback(`Staged new location ${coords.x},${coords.y}. Save the row to move it.`, 'info');
return;
}
if (currentMode === 'add') {
if (existingRowId) {
setFeedback('That square already contains a row. Choose an empty location.', 'error');
return;
}
const key = coordinateKey(coords.x, coords.y);
const multi = event.shiftKey;
if (multi) {
if (addSelection.has(key)) {
removeAddTargetByKey(key);
} else {
stageAddTarget(coords);
}
} else {
if (addSelection.size === 1 && addSelection.has(key)) {
clearAddTargets();
setFeedback('Selection cleared.', 'info');
return;
}
clearAddTargets({ silent: true });
stageAddTarget(coords);
}
updateAddUi();
const size = addSelection.size;
setFeedback(
size
? `Staged ${size} new row${size === 1 ? '' : 's'} for creation.`
: 'No empty squares selected yet.',
'info'
);
return;
}
});
mapViewport.addEventListener('wheel', handleWheel, { passive: false });
mapViewport.addEventListener('mousedown', beginAreaSelection);
window.addEventListener('mousemove', updateAreaSelection);
window.addEventListener('mouseup', finalizeAreaSelection);
$rowForm.on('submit', async (event) => {
event.preventDefault();
if (!selectedRow) {
setFeedback('Select a row to edit before saving changes.', 'info');
return;
}
if (!routes.update) {
console.error('Update route not configured');
setFeedback('Update route is not configured.', 'error');
return;
}
if (relocationTarget) {
const occupied = locationIndex.get(coordinateKey(relocationTarget.x, relocationTarget.y));
if (occupied && Number(occupied) !== Number(selectedRow.id)) {
setFeedback('Another row already occupies the staged location.', 'error');
return;
}
}
const stagedLocation = relocationTarget
? `${relocationTarget.x},${relocationTarget.y}`
: undefined;
const payload = {
rows: [
{
id: selectedRow.id,
vine_count: parseInt($rowForm.find('[name="vine_count"]').val(), 10) || 0,
location: stagedLocation,
},
],
};
try {
await axios.post(routes.update, payload);
const previousKey = coordinateKey(selectedRow.location.x, selectedRow.location.y);
const updatedRow = {
...selectedRow,
vine_count: payload.rows[0].vine_count,
location: stagedLocation
? { x: relocationTarget.x, y: relocationTarget.y }
: selectedRow.location,
};
upsertRow(updatedRow);
selectedRow = updatedRow;
if (stagedLocation) {
locationIndex.delete(previousKey);
locationIndex.set(
coordinateKey(updatedRow.location.x, updatedRow.location.y),
Number(updatedRow.id)
);
}
const element = rowElements.get(Number(updatedRow.id));
if (element) {
const badge = element.querySelector('[data-role="vine-count"]');
if (badge) {
badge.textContent = String(updatedRow.vine_count);
}
element.style.left = `${updatedRow.location.x * (CELL_SIZE + CELL_GAP)}px`;
element.style.top = `${updatedRow.location.y * (CELL_SIZE + CELL_GAP)}px`;
applyStatusClass(element, updatedRow.status);
}
populateRowForm(selectedRow);
stageRelocation(null);
refreshCellStates();
updateContentSize();
const feedbackMessage = stagedLocation
? `Row updated and moved to ${stagedLocation}.`
: 'Row updated successfully.';
setFeedback(feedbackMessage, 'success');
} catch (error) {
console.error('Failed to update row', error);
setFeedback('Unable to update the selected row. Please try again.', 'error');
}
});
$deleteButton.on('click', async (event) => {
event.preventDefault();
if (!selectedRow) {
setFeedback('Select a row first before trying to delete it.', 'info');
return;
}
const destroyRoute = routes.destroy?.replace('__ROW__', String(selectedRow.id));
if (!destroyRoute) {
console.error('Destroy route not configured');
setFeedback('Delete route is not configured.', 'error');
return;
}
if (!window.confirm('Remove the selected row from the vineyard map?')) {
setFeedback('Deletion cancelled.', 'info');
return;
}
try {
await axios.post(destroyRoute, { _method: 'DELETE' });
const rowId = selectedRow.id;
removeRowFromCollections(rowId);
selectedRow = null;
populateRowForm(null);
stageRelocation(null);
refreshCellStates();
updateContentSize();
setFeedback('Row deleted successfully.', 'success');
} catch (error) {
console.error('Failed to remove row', error);
setFeedback('Unable to delete the selected row. Please try again.', 'error');
}
});
$removeCommit.on('click', async () => {
if (currentMode !== 'remove' || removeSelection.size === 0) {
return;
}
if (!routes.remove) {
console.error('Remove route not configured');
setFeedback('Remove route is not configured.', 'error');
return;
}
if (!window.confirm(`Delete ${removeSelection.size} selected row(s)?`)) {
setFeedback('Deletion cancelled.', 'info');
return;
}
const ids = Array.from(removeSelection);
try {
await axios.post(routes.remove, { rows: ids });
ids.forEach((rowId) => {
removeRowFromCollections(rowId);
if (selectedRow && Number(selectedRow.id) === Number(rowId)) {
selectedRow = null;
}
});
populateRowForm(selectedRow);
stageRelocation(null);
updateContentSize();
removeSelection.clear();
updateRemoveUi();
setFeedback(`Deleted ${ids.length} row${ids.length === 1 ? '' : 's'}.`, 'success');
} catch (error) {
console.error('Failed to delete selected rows', error);
setFeedback('Unable to delete the selected rows. Please try again.', 'error');
}
});
$removeClear.on('click', () => {
if (removeSelection.size === 0) {
return;
}
removeSelection.clear();
updateRemoveUi();
setFeedback('Selection cleared.', 'info');
});
}