1171 lines
39 KiB
JavaScript
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');
|
|
});
|
|
}
|