1000 lines
32 KiB
JavaScript
1000 lines
32 KiB
JavaScript
import $ from 'jquery';
|
|
import axios from 'axios';
|
|
|
|
const CELL_SIZE = 80;
|
|
const CELL_GAP = 16;
|
|
const MIN_ZOOM = 0.05;
|
|
const MAX_ZOOM = 3;
|
|
const ZOOM_FACTOR = 0.1;
|
|
const SCROLL_SENSITIVITY = 0.6;
|
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const ACTIONS_REQUIRING_DATE = new Set(['watering', 'pruning', 'fertilisation', 'pesticide', 'harvest']);
|
|
|
|
function showToast(message, type = 'error') {
|
|
let container = document.querySelector('[data-role="toast-container"]');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.dataset.role = 'toast-container';
|
|
container.style.position = 'fixed';
|
|
container.style.top = '1rem';
|
|
container.style.right = '1rem';
|
|
container.style.zIndex = '9999';
|
|
container.style.display = 'flex';
|
|
container.style.flexDirection = 'column';
|
|
container.style.gap = '0.5rem';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.textContent = message;
|
|
toast.style.padding = '0.6rem 0.9rem';
|
|
toast.style.borderRadius = '6px';
|
|
toast.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
|
|
toast.style.color = '#0f172a';
|
|
toast.style.fontSize = '0.9rem';
|
|
toast.style.backgroundColor = type === 'success' ? '#bbf7d0' : '#fee2e2';
|
|
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
if (!container.children.length) {
|
|
container.remove();
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
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';
|
|
|
|
const badgeRow = document.createElement('div');
|
|
badgeRow.classList.add('map-cell-badge-row');
|
|
badgeRow.appendChild(countBadge);
|
|
|
|
const warning = document.createElement('div');
|
|
warning.classList.add('map-cell-warning');
|
|
warning.dataset.role = 'restriction-warning';
|
|
warning.style.display = 'none';
|
|
|
|
const skull = document.createElement('span');
|
|
skull.dataset.role = 'skull-icon';
|
|
skull.textContent = '☠';
|
|
|
|
const warningText = document.createElement('span');
|
|
warningText.dataset.role = 'restriction-text';
|
|
|
|
warning.appendChild(skull);
|
|
warning.appendChild(warningText);
|
|
badgeRow.appendChild(warning);
|
|
|
|
cell.appendChild(badgeRow);
|
|
cell.appendChild(varietyLabel);
|
|
|
|
return cell;
|
|
}
|
|
|
|
function parseISODate(value) {
|
|
if (!value || typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const parts = value.split('-').map((segment) => Number(segment));
|
|
if (parts.length !== 3 || parts.some((number) => Number.isNaN(number))) {
|
|
return null;
|
|
}
|
|
|
|
const [year, month, day] = parts;
|
|
return new Date(Date.UTC(year, month - 1, day));
|
|
}
|
|
|
|
function withinDays(laterDate, earlierDate, windowDays) {
|
|
if (!laterDate || !earlierDate) {
|
|
return false;
|
|
}
|
|
|
|
const diffDays = (laterDate.getTime() - earlierDate.getTime()) / DAY_IN_MS;
|
|
return diffDays >= 0 && diffDays <= windowDays;
|
|
}
|
|
|
|
function daysBetween(laterDate, earlierDate) {
|
|
if (!laterDate || !earlierDate) {
|
|
return null;
|
|
}
|
|
|
|
return Math.round((laterDate.getTime() - earlierDate.getTime()) / DAY_IN_MS);
|
|
}
|
|
|
|
function updateNextState($nextButton, selectedAction, selectedRows, actionDate) {
|
|
const requiresDate = selectedAction ? ACTIONS_REQUIRING_DATE.has(selectedAction) : false;
|
|
const hasDate = !requiresDate || Boolean(actionDate);
|
|
const enable = Boolean(selectedAction) && selectedRows.size > 0 && hasDate;
|
|
$nextButton.prop('disabled', !enable);
|
|
}
|
|
|
|
function activateActionButton($sidebar, actionKey) {
|
|
$sidebar.find('[data-action]').removeClass('active');
|
|
if (actionKey) {
|
|
$sidebar.find(`[data-action="${actionKey}"]`).addClass('active');
|
|
}
|
|
}
|
|
|
|
export default function initMapIndex() {
|
|
const $root = $('[data-page="vineyard-map-index"]');
|
|
if (!$root.length) {
|
|
return;
|
|
}
|
|
|
|
const rows = parseData('vineyard-map-rows', []);
|
|
const routes = parseData('vineyard-map-routes', {});
|
|
|
|
const selectedRows = new Set();
|
|
const rowElements = new Map();
|
|
const rowDataById = new Map();
|
|
let selectedAction = null;
|
|
let actionPromptTimeout = null;
|
|
let selectedActionDate = '';
|
|
|
|
const $sidebar = $root.find('[data-role="sidebar"]');
|
|
const $mapPanel = $root.find('[data-role="map-panel"]');
|
|
const $nextButton = $root.find('[data-control="next-action"]');
|
|
const $clearButton = $root.find('[data-control="clear-selection"]');
|
|
const mapViewport = $root.find('[data-role="map-container"]')[0];
|
|
const actionDetailsSection = $root.find('[data-role="action-details"]')[0] ?? null;
|
|
const actionDateInput = $root.find('[data-control="action-date"]')[0] ?? null;
|
|
const actionTimelineRoot = actionDetailsSection?.querySelector('[data-role="action-timeline"]') ?? null;
|
|
const actionTimelineGroups = actionTimelineRoot
|
|
? {
|
|
pesticide: {
|
|
root: actionTimelineRoot.querySelector('[data-action="pesticide"]') ?? null,
|
|
completed: actionTimelineRoot.querySelector('[data-timeline="pesticide-completed"]') ?? null,
|
|
planned: actionTimelineRoot.querySelector('[data-timeline="pesticide-planned"]') ?? null,
|
|
},
|
|
harvest: {
|
|
root: actionTimelineRoot.querySelector('[data-action="harvest"]') ?? null,
|
|
completed: actionTimelineRoot.querySelector('[data-timeline="harvest-completed"]') ?? null,
|
|
planned: actionTimelineRoot.querySelector('[data-timeline="harvest-planned"]') ?? null,
|
|
},
|
|
}
|
|
: null;
|
|
|
|
if (!mapViewport) {
|
|
return;
|
|
}
|
|
|
|
const $helpBtn1 = $sidebar.find('button[aria-label="Help"]');
|
|
const $tooltip1 = $sidebar.find('.action-tooltip');
|
|
if ($helpBtn1.length && $tooltip1.length) {
|
|
$helpBtn1.on('mouseenter focus', function() {
|
|
$tooltip1.show();
|
|
});
|
|
$helpBtn1.on('mouseleave blur', function() {
|
|
$tooltip1.hide();
|
|
});
|
|
}
|
|
|
|
const $helpBtn2 = $('button[aria-label="Help2"]');
|
|
const $tooltip2 = $('.action-tooltip2');
|
|
|
|
|
|
if ($helpBtn2.length && $tooltip2.length) {
|
|
console.log($helpBtn2, $tooltip2, $helpBtn1);
|
|
|
|
$helpBtn2.on('mouseenter focus', function() {
|
|
$tooltip2.show();
|
|
});
|
|
$helpBtn2.on('mouseleave blur', function() {
|
|
$tooltip2.hide();
|
|
});
|
|
}
|
|
|
|
|
|
|
|
const actionRequiresDate = (actionKey) => {
|
|
if (!actionKey) {
|
|
return false;
|
|
}
|
|
|
|
return ACTIONS_REQUIRING_DATE.has(actionKey);
|
|
};
|
|
|
|
const toggleActionDetails = () => {
|
|
if (!actionDetailsSection || !actionDateInput) {
|
|
return;
|
|
}
|
|
|
|
const requiresDate = actionRequiresDate(selectedAction);
|
|
|
|
if (requiresDate) {
|
|
actionDetailsSection.classList.add('visible');
|
|
actionDetailsSection.setAttribute('aria-hidden', 'false');
|
|
actionDateInput.disabled = false;
|
|
actionDateInput.value = selectedActionDate || '';
|
|
} else {
|
|
actionDetailsSection.classList.remove('visible');
|
|
actionDetailsSection.setAttribute('aria-hidden', 'true');
|
|
actionDateInput.disabled = true;
|
|
actionDateInput.value = '';
|
|
selectedActionDate = '';
|
|
}
|
|
|
|
if (actionTimelineRoot) {
|
|
const timelineVisible = selectedAction === 'harvest' || selectedAction === 'pesticide';
|
|
actionTimelineRoot.classList.toggle('visible', timelineVisible);
|
|
actionTimelineRoot.setAttribute('aria-hidden', timelineVisible ? 'false' : 'true');
|
|
}
|
|
};
|
|
|
|
toggleActionDetails();
|
|
if (actionDateInput) {
|
|
const today = new Date();
|
|
const year = today.getFullYear();
|
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
const day = String(today.getDate()).padStart(2, '0');
|
|
actionDateInput.min = `${year}-${month}-${day}`;
|
|
}
|
|
|
|
const updateTimelineDisplay = () => {
|
|
if (!actionTimelineGroups) {
|
|
return;
|
|
}
|
|
|
|
const aggregated = {
|
|
pesticide: {
|
|
completed: new Set(),
|
|
planned: new Set(),
|
|
},
|
|
harvest: {
|
|
completed: new Set(),
|
|
planned: new Set(),
|
|
},
|
|
};
|
|
|
|
selectedRows.forEach((rowId) => {
|
|
const row = rowDataById.get(rowId);
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
['pesticide', 'harvest'].forEach((category) => {
|
|
const timeline = row.timeline?.[category];
|
|
if (!timeline) {
|
|
return;
|
|
}
|
|
|
|
(timeline.completed ?? []).forEach((date) => {
|
|
if (date) {
|
|
aggregated[category].completed.add(date);
|
|
}
|
|
});
|
|
(timeline.planned ?? []).forEach((date) => {
|
|
if (date) {
|
|
aggregated[category].planned.add(date);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
const timelineVisible = actionTimelineRoot?.classList.contains('visible');
|
|
|
|
['pesticide', 'harvest'].forEach((category) => {
|
|
const group = actionTimelineGroups[category];
|
|
if (!group) {
|
|
return;
|
|
}
|
|
|
|
const completed = Array.from(aggregated[category].completed).sort();
|
|
const planned = Array.from(aggregated[category].planned).sort();
|
|
|
|
if (group.completed) {
|
|
group.completed.textContent = completed.length ? completed.join(', ') : 'None';
|
|
}
|
|
|
|
if (group.planned) {
|
|
group.planned.textContent = planned.length ? planned.join(', ') : 'None';
|
|
}
|
|
|
|
if (group.root) {
|
|
const isRelevant = actionRequiresDate(selectedAction) && (selectedAction === category || selectedAction === 'harvest' || selectedAction === 'pesticide');
|
|
group.root.classList.toggle('active', Boolean(timelineVisible && isRelevant));
|
|
}
|
|
});
|
|
};
|
|
|
|
const requireActionBeforeSelection = () => {
|
|
if (selectedAction) {
|
|
return true;
|
|
}
|
|
|
|
mapViewport.classList.add('requires-action');
|
|
window.clearTimeout(actionPromptTimeout);
|
|
actionPromptTimeout = window.setTimeout(() => {
|
|
mapViewport.classList.remove('requires-action');
|
|
}, 420);
|
|
|
|
return false;
|
|
};
|
|
|
|
const { width: contentWidth, height: contentHeight } = computeContentDimensions(rows);
|
|
|
|
mapViewport.innerHTML = '';
|
|
const mapContent = document.createElement('div');
|
|
mapContent.classList.add('vineyard-map-grid');
|
|
mapContent.style.width = `${contentWidth}px`;
|
|
mapContent.style.height = `${contentHeight}px`;
|
|
mapViewport.appendChild(mapContent);
|
|
|
|
const selectionLayer = document.createElement('div');
|
|
selectionLayer.classList.add('vineyard-map-selection-layer');
|
|
mapViewport.appendChild(selectionLayer);
|
|
|
|
rows.forEach((row) => {
|
|
const cell = createCell(row);
|
|
mapContent.appendChild(cell);
|
|
const rowId = Number(row.id);
|
|
rowElements.set(rowId, cell);
|
|
|
|
const timeline = row.timeline ?? {};
|
|
rowDataById.set(rowId, {
|
|
...row,
|
|
timeline: {
|
|
pesticide: {
|
|
completed: Array.isArray(timeline?.pesticide?.completed) ? timeline.pesticide.completed : [],
|
|
planned: Array.isArray(timeline?.pesticide?.planned) ? timeline.pesticide.planned : [],
|
|
},
|
|
harvest: {
|
|
completed: Array.isArray(timeline?.harvest?.completed) ? timeline.harvest.completed : [],
|
|
planned: Array.isArray(timeline?.harvest?.planned) ? timeline.harvest.planned : [],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const viewportState = {
|
|
scale: 1,
|
|
translateX: 0,
|
|
translateY: 0,
|
|
};
|
|
|
|
let eligibleRows = null;
|
|
|
|
const isRowEligibleForAction = (actionKey, row) => {
|
|
if (!row) {
|
|
return false;
|
|
}
|
|
|
|
const hasVarietyAssigned = Boolean(row.variety);
|
|
|
|
if (['discard-plants', 'harvest', 'watering', 'pruning', 'fertilisation', 'pesticide'].includes(actionKey)) {
|
|
return hasVarietyAssigned;
|
|
}
|
|
|
|
if (actionKey === 'add-plants') {
|
|
return !hasVarietyAssigned;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const isRowEligibleOnDate = (actionKey, row, targetDate) => {
|
|
if (!targetDate) {
|
|
return true;
|
|
}
|
|
|
|
const timeline = row.timeline ?? {};
|
|
const pesticideDates = [
|
|
...(timeline.pesticide?.completed ?? []),
|
|
...(timeline.pesticide?.planned ?? []),
|
|
]
|
|
.map((value) => parseISODate(value))
|
|
.filter(Boolean);
|
|
|
|
const harvestDates = [
|
|
...(timeline.harvest?.completed ?? []),
|
|
...(timeline.harvest?.planned ?? []),
|
|
]
|
|
.map((value) => parseISODate(value))
|
|
.filter(Boolean);
|
|
|
|
if (actionKey === 'harvest') {
|
|
return !pesticideDates.some((pesticideDate) => withinDays(targetDate, pesticideDate, 14));
|
|
}
|
|
|
|
if (actionKey === 'pesticide') {
|
|
return !harvestDates.some((harvestDate) => withinDays(harvestDate, targetDate, 14));
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const computeEligibleRows = (actionKey, actionDate) => {
|
|
if (!actionKey) {
|
|
return null;
|
|
}
|
|
|
|
const eligible = new Set();
|
|
const requiresDate = actionRequiresDate(actionKey);
|
|
const targetDate = requiresDate && actionDate ? parseISODate(actionDate) : null;
|
|
|
|
rowDataById.forEach((row, rowId) => {
|
|
if (!isRowEligibleForAction(actionKey, row)) {
|
|
return;
|
|
}
|
|
|
|
if (requiresDate && targetDate && !isRowEligibleOnDate(actionKey, row, targetDate)) {
|
|
return;
|
|
}
|
|
|
|
eligible.add(rowId);
|
|
});
|
|
|
|
return eligible;
|
|
};
|
|
|
|
const applyRowEligibility = () => {
|
|
let selectionChanged = false;
|
|
rowElements.forEach((element, rowId) => {
|
|
const eligible = !eligibleRows || eligibleRows.has(rowId);
|
|
element.classList.toggle('disabled', !eligible);
|
|
|
|
const warning = element.querySelector('[data-role="restriction-warning"]');
|
|
const warningText = element.querySelector('[data-role="restriction-text"]');
|
|
|
|
if (warning && warningText) {
|
|
warning.style.display = 'none';
|
|
warning.classList.remove('map-cell-warning--pesticide');
|
|
|
|
const row = rowDataById.get(rowId);
|
|
const timeline = row?.timeline ?? {};
|
|
const targetDate = selectedActionDate ? parseISODate(selectedActionDate) : null;
|
|
|
|
if (!eligible && selectedAction === 'harvest' && targetDate) {
|
|
const pesticideDates = [
|
|
...(timeline.pesticide?.completed ?? []),
|
|
...(timeline.pesticide?.planned ?? []),
|
|
]
|
|
.map((value) => parseISODate(value))
|
|
.filter(Boolean);
|
|
|
|
if (pesticideDates.length > 0) {
|
|
let maxRemaining = 0;
|
|
|
|
pesticideDates.forEach((pesticideDate) => {
|
|
const diff = 14 - daysBetween(targetDate, pesticideDate);
|
|
if (Number.isFinite(diff) && diff > maxRemaining) {
|
|
maxRemaining = diff;
|
|
}
|
|
});
|
|
|
|
if (maxRemaining > 0) {
|
|
warningText.textContent = `${maxRemaining}d`;
|
|
const icon = element.querySelector('[data-role="skull-icon"]');
|
|
if (icon) {
|
|
icon.textContent = '☠';
|
|
}
|
|
warning.style.display = 'flex';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!eligible && selectedAction === 'pesticide' && targetDate) {
|
|
const harvestDates = [
|
|
...(timeline.harvest?.completed ?? []),
|
|
...(timeline.harvest?.planned ?? []),
|
|
]
|
|
.map((value) => parseISODate(value))
|
|
.filter(Boolean);
|
|
|
|
if (harvestDates.length > 0) {
|
|
let minUntilHarvest = Infinity;
|
|
|
|
harvestDates.forEach((harvestDate) => {
|
|
const diff = daysBetween(harvestDate, targetDate);
|
|
if (Number.isFinite(diff) && diff >= 0 && diff < minUntilHarvest) {
|
|
minUntilHarvest = diff;
|
|
}
|
|
});
|
|
|
|
if (Number.isFinite(minUntilHarvest) && minUntilHarvest !== Infinity) {
|
|
warningText.textContent = `${minUntilHarvest}d`;
|
|
const icon = element.querySelector('[data-role="skull-icon"]');
|
|
if (icon) {
|
|
icon.textContent = '🍇';
|
|
}
|
|
warning.classList.add('map-cell-warning--pesticide');
|
|
warning.style.display = 'flex';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!eligible && selectedRows.has(rowId)) {
|
|
selectedRows.delete(rowId);
|
|
selectionChanged = true;
|
|
}
|
|
});
|
|
|
|
return selectionChanged;
|
|
};
|
|
|
|
eligibleRows = computeEligibleRows(selectedAction, selectedActionDate);
|
|
applyRowEligibility();
|
|
|
|
applyRowEligibility();
|
|
|
|
const clampTransform = () => {
|
|
const viewportWidth = mapViewport.clientWidth;
|
|
const viewportHeight = mapViewport.clientHeight;
|
|
const scaledWidth = contentWidth * viewportState.scale;
|
|
const scaledHeight = contentHeight * 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 = () => {
|
|
if (!contentWidth || !contentHeight) {
|
|
viewportState.scale = 1;
|
|
viewportState.translateX = 0;
|
|
viewportState.translateY = 0;
|
|
applyTransform();
|
|
return;
|
|
}
|
|
|
|
const viewportWidth = mapViewport.clientWidth || 1;
|
|
const viewportHeight = mapViewport.clientHeight || 1;
|
|
const scaleX = viewportWidth / contentWidth;
|
|
const scaleY = viewportHeight / contentHeight;
|
|
const fittedScale = Math.min(1, Math.min(scaleX, scaleY));
|
|
|
|
viewportState.scale = Math.min(MAX_ZOOM, Math.max(fittedScale, MIN_ZOOM));
|
|
viewportState.translateX = (viewportWidth - contentWidth * viewportState.scale) / 2;
|
|
viewportState.translateY = (viewportHeight - contentHeight * viewportState.scale) / 2;
|
|
|
|
applyTransform();
|
|
};
|
|
|
|
const updateSelectionStyles = () => {
|
|
rowElements.forEach((element, rowId) => {
|
|
if (selectedRows.has(rowId)) {
|
|
element.classList.add('selected');
|
|
} else {
|
|
element.classList.remove('selected');
|
|
}
|
|
});
|
|
};
|
|
|
|
const syncState = () => {
|
|
updateSelectionStyles();
|
|
updateNextState($nextButton, selectedAction, selectedRows, selectedActionDate);
|
|
updateTimelineDisplay();
|
|
};
|
|
|
|
if (actionDateInput) {
|
|
actionDateInput.addEventListener('input', (event) => {
|
|
selectedActionDate = event.target.value;
|
|
|
|
if (selectedActionDate) {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const chosen = parseISODate(selectedActionDate);
|
|
if (chosen && chosen < today) {
|
|
showToast('Planned date cannot be in the past.', 'error');
|
|
}
|
|
}
|
|
eligibleRows = computeEligibleRows(selectedAction, selectedActionDate);
|
|
applyRowEligibility();
|
|
syncState();
|
|
});
|
|
}
|
|
|
|
const clearSelection = () => {
|
|
if (selectedRows.size === 0) {
|
|
return;
|
|
}
|
|
selectedRows.clear();
|
|
syncState();
|
|
};
|
|
|
|
const selectRow = (rowId, multi) => {
|
|
if (eligibleRows && !eligibleRows.has(rowId)) {
|
|
return;
|
|
}
|
|
|
|
if (!multi) {
|
|
selectedRows.clear();
|
|
selectedRows.add(rowId);
|
|
} else if (selectedRows.has(rowId)) {
|
|
selectedRows.delete(rowId);
|
|
} else {
|
|
selectedRows.add(rowId);
|
|
}
|
|
|
|
syncState();
|
|
};
|
|
|
|
const areaSelect = (rowIds, merge) => {
|
|
const candidates = eligibleRows
|
|
? rowIds.filter((rowId) => eligibleRows.has(rowId))
|
|
: rowIds;
|
|
|
|
if (candidates.length === 0) {
|
|
if (!merge && rowIds.length === 0) {
|
|
clearSelection();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!merge) {
|
|
selectedRows.clear();
|
|
}
|
|
|
|
candidates.forEach((rowId) => selectedRows.add(rowId));
|
|
syncState();
|
|
};
|
|
|
|
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;
|
|
viewportState.scale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewportState.scale * scaleDelta));
|
|
|
|
const zoomRatio = viewportState.scale / previousScale;
|
|
const rect = mapViewport.getBoundingClientRect();
|
|
const offsetX = event.clientX - rect.left;
|
|
const offsetY = event.clientY - rect.top;
|
|
|
|
viewportState.translateX = offsetX - (offsetX - viewportState.translateX) * zoomRatio;
|
|
viewportState.translateY = offsetY - (offsetY - viewportState.translateY) * zoomRatio;
|
|
|
|
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;
|
|
}
|
|
|
|
applyTransform();
|
|
};
|
|
|
|
let isSelectingArea = false;
|
|
let selectionStartPoint = null;
|
|
let selectionBox = null;
|
|
let selectionMerge = false;
|
|
let selectionMoved = false;
|
|
let skipClickClear = false;
|
|
|
|
const beginSelection = (event) => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
const cell = event.target.closest('[data-row-id]');
|
|
if (cell && !cell.classList.contains('disabled')) {
|
|
return;
|
|
}
|
|
|
|
if (!requireActionBeforeSelection()) {
|
|
return;
|
|
}
|
|
|
|
isSelectingArea = true;
|
|
selectionMerge = event.shiftKey;
|
|
selectionMoved = false;
|
|
selectionStartPoint = { x: event.clientX, y: event.clientY };
|
|
selectionBox = document.createElement('div');
|
|
selectionBox.classList.add('selection-box');
|
|
selectionLayer.appendChild(selectionBox);
|
|
event.preventDefault();
|
|
};
|
|
|
|
const updateSelectionBox = (event) => {
|
|
if (!isSelectingArea || !selectionBox || !selectionStartPoint) {
|
|
return;
|
|
}
|
|
|
|
const rect = mapViewport.getBoundingClientRect();
|
|
const startX = selectionStartPoint.x - rect.left;
|
|
const startY = selectionStartPoint.y - rect.top;
|
|
const currentX = event.clientX - rect.left;
|
|
const currentY = event.clientY - rect.top;
|
|
|
|
const left = Math.min(startX, currentX);
|
|
const top = Math.min(startY, currentY);
|
|
const width = Math.abs(currentX - startX);
|
|
const height = Math.abs(currentY - startY);
|
|
|
|
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 finishSelection = (moved) => {
|
|
if (!isSelectingArea) {
|
|
return;
|
|
}
|
|
|
|
const boxRect = selectionBox?.getBoundingClientRect() ?? null;
|
|
|
|
selectionBox?.remove();
|
|
selectionBox = null;
|
|
selectionStartPoint = null;
|
|
isSelectingArea = false;
|
|
|
|
if (!moved) {
|
|
skipClickClear = true;
|
|
if (!selectionMerge) {
|
|
clearSelection();
|
|
}
|
|
selectionMerge = false;
|
|
return;
|
|
}
|
|
|
|
if (!boxRect) {
|
|
selectionMerge = false;
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
skipClickClear = true;
|
|
areaSelect(intersectingRows, selectionMerge);
|
|
selectionMerge = false;
|
|
};
|
|
|
|
mapContent.addEventListener('click', (event) => {
|
|
const cell = event.target.closest('[data-row-id]');
|
|
if (!cell) {
|
|
return;
|
|
}
|
|
|
|
if (!requireActionBeforeSelection()) {
|
|
return;
|
|
}
|
|
|
|
if (cell.classList.contains('disabled')) {
|
|
skipClickClear = true;
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
skipClickClear = true;
|
|
event.stopPropagation();
|
|
|
|
const rowId = Number(cell.dataset.rowId);
|
|
if (!Number.isFinite(rowId) || rowId <= 0) {
|
|
return;
|
|
}
|
|
|
|
selectRow(rowId, event.shiftKey);
|
|
});
|
|
|
|
mapViewport.addEventListener('click', (event) => {
|
|
if (skipClickClear) {
|
|
skipClickClear = false;
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest('[data-row-id]')) {
|
|
return;
|
|
}
|
|
|
|
if (!event.shiftKey) {
|
|
clearSelection();
|
|
}
|
|
});
|
|
|
|
mapViewport.addEventListener('mousedown', beginSelection);
|
|
window.addEventListener('mousemove', updateSelectionBox);
|
|
window.addEventListener('mouseup', () => {
|
|
const wasSelecting = isSelectingArea;
|
|
const moved = selectionMoved;
|
|
finishSelection(moved);
|
|
if (wasSelecting && moved) {
|
|
skipClickClear = true;
|
|
}
|
|
selectionMoved = false;
|
|
});
|
|
|
|
mapViewport.addEventListener('wheel', handleWheel, { passive: false });
|
|
|
|
window.addEventListener('resize', applyTransform);
|
|
fitContentToViewport();
|
|
|
|
$sidebar.on('click', '[data-action]', function () {
|
|
const $button = $(this);
|
|
const action = $button.data('action');
|
|
|
|
if (selectedAction === action) {
|
|
selectedAction = null;
|
|
activateActionButton($sidebar, null);
|
|
} else {
|
|
selectedAction = action;
|
|
activateActionButton($sidebar, action);
|
|
}
|
|
|
|
if (selectedAction) {
|
|
mapViewport.classList.remove('requires-action');
|
|
}
|
|
|
|
toggleActionDetails();
|
|
|
|
eligibleRows = computeEligibleRows(selectedAction, selectedActionDate);
|
|
applyRowEligibility();
|
|
|
|
syncState();
|
|
});
|
|
$clearButton.on('click', () => {
|
|
clearSelection();
|
|
});
|
|
|
|
$nextButton.on('click', async (event) => {
|
|
event.preventDefault();
|
|
|
|
if (!selectedAction || selectedRows.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const rowList = Array.from(selectedRows.values());
|
|
|
|
if (selectedAction === 'discard-plants') {
|
|
const endpoint = routes['discard-plants'];
|
|
if (!endpoint) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await axios.post(endpoint, { rows: rowList });
|
|
|
|
rowList.forEach((rowId) => {
|
|
const element = rowElements.get(rowId);
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
element.classList.remove('selected');
|
|
// Backend now marks rows inactive but keeps vine counts intact.
|
|
applyStatusClass(element, 'inactive');
|
|
|
|
const data = rowDataById.get(rowId) ?? {};
|
|
rowDataById.set(rowId, {
|
|
...data,
|
|
status: 'inactive',
|
|
variety: null,
|
|
variety_variation_id: null,
|
|
});
|
|
|
|
const varietyLabel = element.querySelector('[data-role="variety-label"]');
|
|
if (varietyLabel) {
|
|
varietyLabel.textContent = 'Unassigned';
|
|
}
|
|
});
|
|
|
|
eligibleRows = computeEligibleRows(selectedAction, selectedActionDate);
|
|
applyRowEligibility();
|
|
selectedRows.clear();
|
|
syncState();
|
|
} catch (error) {
|
|
console.error('Failed to discard plants', error);
|
|
alert('Discard action failed. Please try again.');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const endpoint = routes[selectedAction];
|
|
if (!endpoint) {
|
|
return;
|
|
}
|
|
|
|
if (actionRequiresDate(selectedAction) && !selectedActionDate) {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(endpoint, window.location.origin);
|
|
url.searchParams.set('rows', rowList.join(','));
|
|
if (actionRequiresDate(selectedAction)) {
|
|
url.searchParams.set('date', selectedActionDate);
|
|
}
|
|
window.location.href = url.toString();
|
|
});
|
|
|
|
syncState();
|
|
}
|