-
-
-
- -
-
- Points (0) -
-
- Click on the map to add points -
-
-
-
+ + + +
+
+ + + + + +
+ + + + + + +
+
+ +
+
+
+ + + + + +
+ + +
+
+
+

Points

+ 0 +
+
+
+
+ + +
+

Settings

+
+
+
+
Date & Time
+
+ + +
+
+ +
+
Track Settings
+
+ +
- -
- Ready to create GPX track -
- -
+
+ +
+
+
+
Elevation & GPS
+
+ + +
+
+ + +
+
+ + +
+
- - -
-
-
-

About GPX Generator

- -
-
-

This interactive GPX generator is designed for creating realistic GPS test data for location-based applications. It's particularly useful for testing and development of GPS tracking systems, route planning applications, and location analytics tools.

- -

Features:

-
    -
  • Interactive map-based point creation
  • -
  • Realistic speed and elevation simulation
  • -
  • Customizable GPS accuracy and timing
  • -
  • Multiple track support with export capabilities
  • -
  • Paint mode for continuous track drawing
  • -
- -

Use Cases:

-
    -
  • Testing location-based mobile applications
  • -
  • Creating sample data for GPS analytics
  • -
  • Simulating user movement patterns
  • -
  • Generating test routes for navigation systems
  • -
- -

This tool is part of the Reitti project - an open-source location tracking and analytics platform.

-
+
+
+
Options
+
+ +
+
+
+
Time Adjustment
+
+ +
+ + + + +
+
+
+
+
Drawing
+ + +
+
+
- -
-
-
-

Select Dates to Import

- -
-
-

Select a range of dates from the Google Records file. Click a date to select it, or use Shift+Click to select a range.

-
-
- - -
-
-
+ + + + + + + + + + + - - - + + + + + + container.appendChild(prevBtn); + container.appendChild(nextBtn); + body.appendChild(container); + } finally { + navButtonsGuard = false; + } + } + + const pointInfoBody = document.getElementById('pointInfoBody'); + if (pointInfoBody) { + const bodyObserver = new MutationObserver(addNavButtons); + bodyObserver.observe(pointInfoBody, { childList: true }); + } + const pointInfo = document.getElementById('pointInfo'); + if (pointInfo) { + let visibilityGuard = false; + const visibilityObserver = new MutationObserver(function() { + if (visibilityGuard) return; + try { + visibilityGuard = true; + if (pointInfo.style.display !== 'none') addNavButtons(); + } finally { + visibilityGuard = false; + } + }); + visibilityObserver.observe(pointInfo, { attributes: true, attributeFilter: ['style'] }); + } + })(); + + + + diff --git a/docs/tools/gpx-generator/js/datetime-picker.js b/docs/tools/gpx-generator/js/datetime-picker.js new file mode 100644 index 000000000..e90192cf8 --- /dev/null +++ b/docs/tools/gpx-generator/js/datetime-picker.js @@ -0,0 +1,703 @@ +/** + * DateTimePicker - A custom datetime picker component + * Provides calendar, year selection, and (optionally) time selection. + * + * Modes: + * - Full mode: container has both .date-input and .time-input + * - Date-only: container has only .date-input + * (time is always 00:00:00 in selectedDate and getValue) + */ +class DateTimePicker { + /** + * Creates a new DateTimePicker instance + * @param {HTMLElement} element - The container element + * @param {Object} options - Configuration options + * @param {string} options.timeFormat - Time format ('12h' or '24h') + * @param {Date} options.minDate - Minimum selectable date + * @param {Date} options.maxDate - Maximum selectable date + * @param {Function} options.onValidate - Validation callback function + * @param {string} options.locale - Locale for date formatting + */ + constructor(element, options = {}) { + this.options = { + timeFormat: options.timeFormat || '24h', + minDate: options.minDate || null, + maxDate: options.maxDate || null, + onValidate: options.onValidate || null, + locale: options.locale || navigator.language, + popupPlacement: options.popupPlacement || 'auto' // 'top' | 'bottom' | 'auto' + }; + + this.element = element; + this.dateInput = element.querySelector('.date-input'); + this.timeInput = element.querySelector('.time-input'); + this.triggerButton = element.querySelector('.picker-trigger'); + + // Date-only mode when no time input exists + this.dateOnly = !this.timeInput; + + // Create popup structure if it doesn't exist + if (!element.querySelector('.picker-popup')) { + this.createPopupStructure(); + } + + this.popup = element.querySelector('.picker-popup'); + this.calendarSection = element.querySelector('.calendar-section'); + this.yearScroll = element.querySelector('.year-scroll'); + this.timeScroll = element.querySelector('.time-scroll'); + this._listeners = { change: [] }; + this.currentDate = new Date(); + this.selectedDate = null; + + this.init(); + } + + /** + * Create the popup structure dynamically + */ + createPopupStructure() { + const popup = document.createElement('div'); + popup.className = 'picker-popup'; + popup.style.display = 'none'; + + const pickerContainer = document.createElement('div'); + pickerContainer.className = 'picker-container'; + if (this.dateOnly) pickerContainer.classList.add('date-only'); + + // Calendar section + const calendarSection = document.createElement('div'); + calendarSection.className = 'calendar-section'; + + const calendarHeader = document.createElement('div'); + calendarHeader.className = 'calendar-header'; + + const prevMonthBtn = document.createElement('button'); + prevMonthBtn.type = 'button'; + prevMonthBtn.className = 'prev-month'; + prevMonthBtn.innerHTML = '<'; + + const monthYear = document.createElement('div'); + monthYear.className = 'month-year'; + + const nextMonthBtn = document.createElement('button'); + nextMonthBtn.type = 'button'; + nextMonthBtn.className = 'next-month'; + nextMonthBtn.innerHTML = '>'; + + calendarHeader.appendChild(prevMonthBtn); + calendarHeader.appendChild(monthYear); + calendarHeader.appendChild(nextMonthBtn); + + const weekdays = document.createElement('div'); + weekdays.className = 'weekdays'; + + const daysGrid = document.createElement('div'); + daysGrid.className = 'days-grid'; + + // Today button + const todayButton = document.createElement('button'); + todayButton.type = 'button'; + todayButton.className = 'today-button'; + todayButton.textContent = 'Today'; + + calendarSection.appendChild(calendarHeader); + calendarSection.appendChild(weekdays); + calendarSection.appendChild(daysGrid); + calendarSection.appendChild(todayButton); + + // Year scroll section + const yearScroll = document.createElement('div'); + yearScroll.className = 'year-scroll'; + + const yearList = document.createElement('div'); + yearList.className = 'year-list'; + + yearScroll.appendChild(yearList); + + pickerContainer.appendChild(calendarSection); + pickerContainer.appendChild(yearScroll); + + // Time scroll section — only in full mode + if (!this.dateOnly) { + const timeScroll = document.createElement('div'); + timeScroll.className = 'time-scroll'; + + const timeList = document.createElement('div'); + timeList.className = 'time-list'; + + timeScroll.appendChild(timeList); + pickerContainer.appendChild(timeScroll); + } + + popup.appendChild(pickerContainer); + this.element.appendChild(popup); + } + + /** + * Initialize the datetime picker + */ + init() { + this.setupEventListeners(); + this.renderCalendar(); + this.renderYearList(); + if (!this.dateOnly) this.renderTimeList(); + this.updateFromInputs(); + + // Preselect current date and closest time if no date is selected + if (!this.selectedDate) { + this.selectToday(); + } + } + + /** + * Set up event listeners for the picker + */ + setupEventListeners() { + // Trigger button click handler + this.triggerButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.togglePopup(); + }); + + // Input change events + this.dateInput.addEventListener('change', () => { + this.updateFromInputs(); + if (this.options.onValidate) { + this.options.onValidate(); + } + }); + + if (this.timeInput) { + this.timeInput.addEventListener('change', () => { + this.updateFromInputs(); + if (this.options.onValidate) { + this.options.onValidate(); + } + }); + } + + // Calendar navigation + const prevMonthBtn = this.element.querySelector('.prev-month'); + const nextMonthBtn = this.element.querySelector('.next-month'); + const todayBtn = this.element.querySelector('.today-button'); + + if (prevMonthBtn) { + prevMonthBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.currentDate.setMonth(this.currentDate.getMonth() - 1); + this.renderCalendar(); + }); + } + + if (nextMonthBtn) { + nextMonthBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.currentDate.setMonth(this.currentDate.getMonth() + 1); + this.renderCalendar(); + }); + } + + if (todayBtn) { + todayBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.selectToday(); + }); + } + + // Close popup when clicking outside + document.addEventListener('click', (e) => { + if (!this.element.contains(e.target)) { + this.closePopup(); + } + }); + + // Prevent popup from closing when clicking inside it + this.popup.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + /** + * Select today's date (and, in full mode, closest 15-min time). + * In date-only mode, time is pinned to 00:00:00. + */ + selectToday() { + const now = new Date(); + this.currentDate = new Date(now); + this.selectedDate = new Date(now); + + if (this.dateOnly) { + this.selectedDate.setHours(0, 0, 0, 0); + } else { + // Round to nearest 15 minutes + const minutes = Math.round(now.getMinutes() / 15) * 15; + this.selectedDate.setMinutes(minutes, 0, 0); + } + + this.updateInputs(); + this.renderCalendar(); + this.renderYearList(); + if (!this.dateOnly) this.highlightSelectedTime(); + } + + /** + * Render the calendar grid + */ + renderCalendar() { + const monthYear = this.element.querySelector('.month-year'); + const weekdays = this.element.querySelector('.weekdays'); + const daysGrid = this.element.querySelector('.days-grid'); + + // Update month/year header + monthYear.textContent = this.currentDate.toLocaleDateString(this.options.locale, { + month: 'long', + year: 'numeric' + }); + + // Render weekdays + weekdays.innerHTML = ''; + const weekdayNames = []; + for (let i = 0; i < 7; i++) { + const date = new Date(2023, 0, i + 1); // Start from Sunday + weekdayNames.push(date.toLocaleDateString(this.options.locale, { weekday: 'short' })); + } + weekdayNames.forEach(day => { + const dayElement = document.createElement('div'); + dayElement.className = 'weekday'; + dayElement.textContent = day; + weekdays.appendChild(dayElement); + }); + + // Render days grid + daysGrid.innerHTML = ''; + const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); + + for (let i = 0; i < 42; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + + const dayElement = document.createElement('button'); + dayElement.type = 'button'; + dayElement.className = 'day'; + dayElement.textContent = date.getDate(); + + if (date.getMonth() !== this.currentDate.getMonth()) { + dayElement.classList.add('other-month'); + } + + if (this.selectedDate && this.isSameDay(date, this.selectedDate)) { + dayElement.classList.add('selected'); + } + + if (this.isDateDisabled(date)) { + dayElement.disabled = true; + dayElement.classList.add('disabled'); + } + + dayElement.addEventListener('click', (e) => { + e.stopPropagation(); + if (!this.isDateDisabled(date)) { + this.selectDate(date); + } + }); + + daysGrid.appendChild(dayElement); + } + } + + /** + * Render the year selection list + */ + renderYearList() { + const yearList = this.element.querySelector('.year-list'); + yearList.innerHTML = ''; + + const currentYear = new Date().getFullYear(); + const startYear = currentYear - 50; + const endYear = currentYear + 50; + + for (let year = startYear; year <= endYear; year++) { + const yearElement = document.createElement('button'); + yearElement.type = 'button'; + yearElement.className = 'year-item'; + yearElement.textContent = year; + + if (year === this.currentDate.getFullYear()) { + yearElement.classList.add('selected'); + } + + yearElement.addEventListener('click', (e) => { + e.stopPropagation(); + this.changeYear(year); + }); + + yearList.appendChild(yearElement); + } + } + + /** + * Change the current year and maintain the selected day if possible + * @param {number} year - The year to change to + */ + changeYear(year) { + const currentDay = this.selectedDate ? this.selectedDate.getDate() : 1; + const currentMonth = this.selectedDate ? this.selectedDate.getMonth() : this.currentDate.getMonth(); + + this.currentDate.setFullYear(year); + + let newDate = new Date(year, currentMonth, currentDay); + + if (newDate.getMonth() !== currentMonth || this.isDateDisabled(newDate)) { + const lastDayOfMonth = new Date(year, currentMonth + 1, 0).getDate(); + newDate = new Date(year, currentMonth, Math.min(currentDay, lastDayOfMonth)); + + if (this.isDateDisabled(newDate)) { + newDate = this.findNextValidDate(newDate); + } + } + + if (this.selectedDate) { + this.selectedDate.setFullYear(year, currentMonth, newDate.getDate()); + if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0); + this.updateInputs(); + } + + this.renderCalendar(); + this.renderYearList(); + } + + /** + * Find the next valid date starting from a given date + */ + findNextValidDate(startDate) { + let currentDate = new Date(startDate); + const maxAttempts = 31; + + for (let i = 0; i < maxAttempts; i++) { + currentDate.setDate(currentDate.getDate() + 1); + if (!this.isDateDisabled(currentDate)) { + return currentDate; + } + } + + return new Date(startDate.getFullYear(), startDate.getMonth(), 1); + } + + /** + * Render the time selection list (full mode only) + */ + renderTimeList() { + if (this.dateOnly) return; + const timeList = this.element.querySelector('.time-list'); + if (!timeList) return; + timeList.innerHTML = ''; + + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 15) { + const timeElement = document.createElement('button'); + timeElement.type = 'button'; + timeElement.className = 'time-item'; + + const timeString = this.formatTime(hour, minute); + timeElement.textContent = timeString; + timeElement.dataset.hour = hour; + timeElement.dataset.minute = minute; + + timeElement.addEventListener('click', (e) => { + e.stopPropagation(); + this.selectTime(hour, minute); + }); + + timeList.appendChild(timeElement); + } + } + } + + /** + * Format time according to the specified format + */ + formatTime(hour, minute) { + if (this.options.timeFormat === '12h') { + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`; + } else { + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + } + } + /** + * Update the picker's displayed date without firing the 'change' event. + * Useful for transient sync (e.g., hover tracking) that shouldn't be + * treated as a user selection. + * @param {Date} date + */ + setDateSilent(date) { + if (!(date instanceof Date) || isNaN(date.getTime())) return; + + this.selectedDate = new Date(date); + if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0); + this.currentDate = new Date(this.selectedDate); + + // Update inputs without triggering change on them either + const y = this.selectedDate.getFullYear(); + const m = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0'); + const d = this.selectedDate.getDate().toString().padStart(2, '0'); + this.dateInput.value = `${y}-${m}-${d}`; + + if (this.timeInput) { + const hh = this.selectedDate.getHours().toString().padStart(2, '0'); + const mm = this.selectedDate.getMinutes().toString().padStart(2, '0'); + this.timeInput.value = `${hh}:${mm}`; + } + + this.renderCalendar(); + this.renderYearList(); + if (!this.dateOnly) this.highlightSelectedTime(); + } + /** + * Select a specific date. In date-only mode the time is pinned to 00:00:00. + */ + selectDate(date) { + if (!this.selectedDate) { + this.selectedDate = new Date(); + } + this.selectedDate.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0); + this.updateInputs(); + this.renderCalendar(); + } + + /** + * Select a specific time (no-op in date-only mode) + */ + selectTime(hour, minute) { + if (this.dateOnly) return; + if (!this.selectedDate) { + this.selectedDate = new Date(); + } + this.selectedDate.setHours(hour, minute, 0, 0); + this.updateInputs(); + this.highlightSelectedTime(); + } + + /** + * Highlight the currently selected time in the time list (full mode only) + */ + highlightSelectedTime() { + if (this.dateOnly) return; + this.element.querySelectorAll('.time-item').forEach(item => { + item.classList.remove('selected'); + if (this.selectedDate && + parseInt(item.dataset.hour) === this.selectedDate.getHours() && + parseInt(item.dataset.minute) === this.selectedDate.getMinutes()) { + item.classList.add('selected'); + } + }); + } + + updateInputs() { + if (!this.selectedDate) return; + + const year = this.selectedDate.getFullYear(); + const month = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0'); + const day = this.selectedDate.getDate().toString().padStart(2, '0'); + this.dateInput.value = `${year}-${month}-${day}`; + this.dateInput.dispatchEvent(new Event('change')); + + if (this.timeInput) { + const hours = this.selectedDate.getHours().toString().padStart(2, '0'); + const minutes = this.selectedDate.getMinutes().toString().padStart(2, '0'); + this.timeInput.value = `${hours}:${minutes}`; + this.timeInput.dispatchEvent(new Event('change')); + } + + this._emit('change'); + } + + /** + * Update the picker state from the input values + */ + updateFromInputs() { + if (!this.dateInput.value) return; + + if (this.dateOnly) { + // Parse YYYY-MM-DD as local midnight + const [y, m, d] = this.dateInput.value.split('-').map(Number); + this.selectedDate = new Date(y, m - 1, d, 0, 0, 0, 0); + } else if (this.timeInput && this.timeInput.value) { + const dateTimeString = `${this.dateInput.value}T${this.timeInput.value}`; + this.selectedDate = new Date(dateTimeString); + } else { + return; + } + + this.currentDate = new Date(this.selectedDate); + this.renderCalendar(); + this.renderYearList(); + if (!this.dateOnly) this.highlightSelectedTime(); + + this._emit('change'); + } + + /** + * Toggle the popup visibility + */ + togglePopup() { + const isVisible = this.popup.style.display !== 'none'; + if (isVisible) { + this.closePopup(); + } else { + this.openPopup(); + } + } + + openPopup() { + this.popup.style.display = 'block'; + this.applyPopupPlacement(); + if (this.selectedDate) { + this.scrollToSelectedYear(); + if (!this.dateOnly) this.scrollToSelectedTime(); + } + } + + /** + * Decide whether the popup should render above or below the trigger. + * - 'top' / 'bottom': explicit, always used + * - 'auto': flip to top only if there isn't enough room below + */ + applyPopupPlacement() { + const mode = this.options.popupPlacement; + let placeOnTop; + + if (mode === 'top') placeOnTop = true; + else if (mode === 'bottom') placeOnTop = false; + else { + // auto — measure available space + const triggerRect = this.triggerButton.getBoundingClientRect(); + const popupHeight = this.popup.offsetHeight; + const viewportH = window.innerHeight; + const spaceBelow = viewportH - triggerRect.bottom; + const spaceAbove = triggerRect.top; + // Prefer bottom, but flip if it would clip and top has more room + placeOnTop = spaceBelow < popupHeight && spaceAbove > spaceBelow; + } + + this.popup.classList.toggle('placement-top', placeOnTop); + this.popup.classList.toggle('placement-bottom', !placeOnTop); + } + /** + * Close the popup + */ + closePopup() { + this.popup.style.display = 'none'; + } + + /** + * Scroll to the selected year in the year list + */ + scrollToSelectedYear() { + const selectedYear = this.element.querySelector('.year-item.selected'); + if (selectedYear) { + selectedYear.scrollIntoView({ block: 'center' }); + } + } + + /** + * Scroll to the selected time in the time list (full mode only) + */ + scrollToSelectedTime() { + if (this.dateOnly) return; + const selectedTime = this.element.querySelector('.time-item.selected'); + if (selectedTime) { + selectedTime.scrollIntoView({ block: 'center' }); + } + } + + /** + * Check if two dates are the same day + */ + isSameDay(date1, date2) { + return date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate(); + } + + /** + * Check if a date is disabled + */ + isDateDisabled(date) { + if (this.options.minDate && date < this.options.minDate) { + return true; + } + return this.options.maxDate && date > this.options.maxDate; + + } + + /** + * Get the current value as ISO string. + * Date-only mode returns 'YYYY-MM-DDT00:00:00'. + */ + getValue() { + if (!this.dateInput.value) return ''; + if (this.dateOnly) { + return `${this.dateInput.value}T00:00:00`; + } + if (this.timeInput && this.timeInput.value) { + return `${this.dateInput.value}T${this.timeInput.value}`; + } + return ''; + } + + /** + * Set the value from ISO string. In date-only mode the time portion + * is ignored and the date is stored with time pinned to 00:00:00. + */ + setValue(value) { + if (!value) return; + const [datePart, timePart] = value.split('T'); + this.dateInput.value = datePart; + if (!this.dateOnly && this.timeInput) { + this.timeInput.value = timePart ? timePart.substring(0, 5) : ''; + } + this.updateFromInputs(); + } + + /** + * Subscribe to a picker event. + * @param {string} eventName - Currently supported: 'change' + * @param {Function} handler - Called with (value, selectedDate, picker) + * @returns {Function} Unsubscribe function + */ + on(eventName, handler) { + if (!this._listeners[eventName]) this._listeners[eventName] = []; + this._listeners[eventName].push(handler); + return () => this.off(eventName, handler); + } + + /** + * Unsubscribe a previously registered handler. + */ + off(eventName, handler) { + const arr = this._listeners[eventName]; + if (!arr) return; + const i = arr.indexOf(handler); + if (i >= 0) arr.splice(i, 1); + } + + /** + * Internal: invoke all handlers for an event. + */ + _emit(eventName) { + const arr = this._listeners[eventName]; + if (!arr || !arr.length) return; + const value = this.getValue(); + const selectedDate = this.selectedDate ? new Date(this.selectedDate) : null; + for (const h of arr) { + try { h(value, selectedDate, this); } + catch (err) { console.error(`[DateTimePicker] ${eventName} handler threw:`, err); } + } + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index df2b9f8a7..dc8552396 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.10 + 3.5.14 com.dedicatedcode @@ -65,6 +65,11 @@ org.springframework.boot spring-boot-starter-data-redis + + com.github.kagkarlsson + db-scheduler-spring-boot-starter + 16.7.0 + org.springframework.boot spring-boot-starter-cache @@ -152,7 +157,12 @@ spring-security-test test - + + com.github.docker-java + docker-java-api + 3.7.0 + compile + diff --git a/scripts/dead-message-keys.sh b/scripts/dead-message-keys.sh new file mode 100755 index 000000000..b92419e1c --- /dev/null +++ b/scripts/dead-message-keys.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Define paths +PROPERTIES_FILE="src/main/resources/messages.properties" +SOURCE_DIR="src/main" + +echo "Scanning for unused keys..." +echo "------------------------------------------------" + +# Extract all keys, ignoring comments and empty lines +keys=$(grep -v '^\s*[#!]' "$PROPERTIES_FILE" | grep -v '^\s*$' | cut -d'=' -f1 | sed 's/^[ \t]*//;s/[ \t]*$//') + +for key in $keys; do + # Check if the key is a frontend JS key + if [[ "$key" == js.* ]]; then + # Strip the "js." prefix + search_term="${key#js.}" + + # Search for t('key') or t("key") using Extended Regex (-E) + # We escape the parentheses and quotes for the regex engine + if ! grep -rqE "\bt\(['\"]${search_term}['\"]" "$SOURCE_DIR"; then + echo "[Frontend] Unused JS key : $key" + fi + else + # It's a backend/Thymeleaf key. + # Search for the literal key (e.g., inside #{my.key} or Java code) + if ! grep -rq "$key" "$SOURCE_DIR"; then + echo "[Backend] Unused key : $key" + fi + fi +done + +echo "------------------------------------------------" +echo "Scan complete. Please manually verify before deleting!" \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/config/RedisQueueConfiguration.java b/src/main/java/com/dedicatedcode/reitti/config/RedisQueueConfiguration.java deleted file mode 100644 index ae5ed3c9c..000000000 --- a/src/main/java/com/dedicatedcode/reitti/config/RedisQueueConfiguration.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.dedicatedcode.reitti.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.RedisSerializer; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -@Configuration -public class RedisQueueConfiguration { - - @Bean - public RedisSerializer redisValueSerializer() { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - objectMapper.activateDefaultTyping( - BasicPolymorphicTypeValidator.builder() - .allowIfBaseType(Object.class) - .build(), - ObjectMapper.DefaultTyping.NON_FINAL - ); - return new GenericJackson2JsonRedisSerializer(objectMapper); - } - - @Bean - public RedisTemplate redisTemplate( - RedisConnectionFactory connectionFactory, - RedisSerializer redisValueSerializer - ) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - - StringRedisSerializer keySerializer = new StringRedisSerializer(); - - template.setKeySerializer(keySerializer); - template.setHashKeySerializer(keySerializer); - template.setValueSerializer(redisValueSerializer); - template.setHashValueSerializer(redisValueSerializer); - - template.afterPropertiesSet(); - return template; - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java index 72427a3c0..be06230fc 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java +++ b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.dedicatedcode.reitti.config; +import com.dedicatedcode.reitti.config.security.*; import com.dedicatedcode.reitti.model.Role; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/dedicatedcode/reitti/config/TaskConfig.java b/src/main/java/com/dedicatedcode/reitti/config/TaskConfig.java new file mode 100644 index 000000000..8e3b905e2 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/config/TaskConfig.java @@ -0,0 +1,100 @@ +package com.dedicatedcode.reitti.config; + +import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent; +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.DataCleanupService; +import com.dedicatedcode.reitti.service.UserSseEmitterService; +import com.dedicatedcode.reitti.service.geocoding.ReverseGeocodingListener; +import com.dedicatedcode.reitti.service.importer.PromotionJobHandler; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.jobs.VisitSensitivityConfigurationRecalculationTask; +import com.dedicatedcode.reitti.service.processing.*; +import com.github.kagkarlsson.scheduler.task.Task; +import com.github.kagkarlsson.scheduler.task.helper.Tasks; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.UUID; + +@Configuration +public class TaskConfig { + + @Bean + public Task patchDeviceOntoTimelineTask(PatchDeviceOntoTimelineJob handler) { + return Tasks.oneTime("patch-device-onto-timeline-task", PatchDeviceOntoTimelineJob.TaskData.class) + .execute((instance, context) -> { + PatchDeviceOntoTimelineJob.TaskData data = instance.getData(); + handler.execute(data); + }); + } + + @Bean + public Task sseEmitterTask(UserSseEmitterService userSseEmitterService) { + return Tasks.oneTime("sse-emitter-task", UserSseEmitterService.TaskData.class) + .execute((instance, context) -> { + UserSseEmitterService.TaskData data = instance.getData(); + userSseEmitterService.sendEventToUser(data.user(), data.eventData()); + }); + } + + @Bean + public Task significantPlaceCreatedTask(ReverseGeocodingListener reverseGeocodingListener) { + return Tasks.oneTime("reverse-geocoding-task", SignificantPlaceCreatedEvent.class) + .execute((instance, context) -> { + SignificantPlaceCreatedEvent data = instance.getData(); + reverseGeocodingListener.handleSignificantPlaceCreated(data); + }); + } + + @Bean + public Task updateCuratedTimelineTask(UpdateCuratedTimelineJob handler) { + return Tasks.oneTime("updating-curated-timeline-task", UpdateCuratedTimelineJob.TaskData.class) + .execute((instance, context) -> { + handler.execute(instance.getData()); + }); + } + + @Bean + public Task processingPipelineTask(ProcessingPipelineTrigger handler) { + return Tasks.oneTime("processing-pipeline-task", TriggerProcessingEvent.class) + .execute((instance, context) -> { + handler.execute(instance.getData()); + }); + } + + @Bean + public Task locationDataCleanupTask(LocationDataCleanupJob handler) { + return Tasks.oneTime("location-data-cleanup-task", LocationDataCleanupJob.TaskData.class) + .execute((instance, context) -> { + handler.execute(instance.getData()); + }); + } + + @Bean + public Task promotionTask(PromotionJobHandler handler) { + return Tasks.oneTime("promotion-task", PromotionJobHandler.PromotionTaskData.class) + .execute((instance, context) -> { + handler.execute(instance.getData()); + }); + } + + @Bean + public Task polygonUpdateTask(DataCleanupService handler) { + return Tasks.oneTime("polygon-update-task", DataCleanupService.TaskData.class) + .execute((instance, context) -> { + DataCleanupService.TaskData data = instance.getData(); + handler.execute(data); + }); + } + + @Bean + public Task dataRecalculationTask(VisitSensitivityConfigurationRecalculationTask handler) { + return Tasks.oneTime("data-recalculation-task", VisitSensitivityConfigurationRecalculationTask.TaskData.class) + .execute((instance, context) -> { + VisitSensitivityConfigurationRecalculationTask.TaskData data = instance.getData(); + handler.execute(data); + }); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/config/BaseTokenAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/BaseTokenAuthenticationFilter.java similarity index 94% rename from src/main/java/com/dedicatedcode/reitti/config/BaseTokenAuthenticationFilter.java rename to src/main/java/com/dedicatedcode/reitti/config/security/BaseTokenAuthenticationFilter.java index 7ff65a17a..0a0e0e24e 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/BaseTokenAuthenticationFilter.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/BaseTokenAuthenticationFilter.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.service.ApiTokenService; import jakarta.servlet.http.HttpServletRequest; @@ -12,7 +12,6 @@ protected BaseTokenAuthenticationFilter(ApiTokenService apiTokenService) { } protected void trackApiTokenUsage(HttpServletRequest request, String token) { - // Extract the path and remote IP of the request, supporting reverse proxy String requestPath = request.getRequestURI(); String remoteIp = getClientIpAddress(request); this.apiTokenService.trackUsage(token, requestPath, remoteIp); diff --git a/src/main/java/com/dedicatedcode/reitti/config/CustomAuthenticationSuccessHandler.java b/src/main/java/com/dedicatedcode/reitti/config/security/CustomAuthenticationSuccessHandler.java similarity index 97% rename from src/main/java/com/dedicatedcode/reitti/config/CustomAuthenticationSuccessHandler.java rename to src/main/java/com/dedicatedcode/reitti/config/security/CustomAuthenticationSuccessHandler.java index 76fd290a9..261b95dfc 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/CustomAuthenticationSuccessHandler.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/CustomAuthenticationSuccessHandler.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSettings; diff --git a/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java b/src/main/java/com/dedicatedcode/reitti/config/security/CustomOidcUserService.java similarity index 99% rename from src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java rename to src/main/java/com/dedicatedcode/reitti/config/security/CustomOidcUserService.java index a6801dd42..d492b21f5 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/CustomOidcUserService.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.model.security.ExternalUser; import com.dedicatedcode.reitti.model.security.User; @@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; -import java.io.IOException; import java.net.URI; import java.util.Optional; diff --git a/src/main/java/com/dedicatedcode/reitti/config/HtmxAuthenticationEntryPoint.java b/src/main/java/com/dedicatedcode/reitti/config/security/HtmxAuthenticationEntryPoint.java similarity index 96% rename from src/main/java/com/dedicatedcode/reitti/config/HtmxAuthenticationEntryPoint.java rename to src/main/java/com/dedicatedcode/reitti/config/security/HtmxAuthenticationEntryPoint.java index 80fa93d75..4bdad3547 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/HtmxAuthenticationEntryPoint.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/HtmxAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.service.ContextPathHolder; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationFilter.java similarity index 99% rename from src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationFilter.java rename to src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationFilter.java index 208878193..bce73cc88 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationFilter.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationFilter.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.model.security.MagicLinkResourceType; import com.dedicatedcode.reitti.model.security.MagicLinkToken; diff --git a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationToken.java b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationToken.java similarity index 93% rename from src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationToken.java rename to src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationToken.java index 7502fd860..27832e838 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationToken.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; diff --git a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkSessionValidationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkSessionValidationFilter.java similarity index 97% rename from src/main/java/com/dedicatedcode/reitti/config/MagicLinkSessionValidationFilter.java rename to src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkSessionValidationFilter.java index 69c71f39c..21ff6ef2d 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkSessionValidationFilter.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkSessionValidationFilter.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.model.security.MagicLinkToken; import com.dedicatedcode.reitti.repository.MagicLinkJdbcService; diff --git a/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java b/src/main/java/com/dedicatedcode/reitti/config/security/OidcSecurityConfiguration.java similarity index 98% rename from src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java rename to src/main/java/com/dedicatedcode/reitti/config/security/OidcSecurityConfiguration.java index 6a4b61d1e..bd2a2e543 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/OidcSecurityConfiguration.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; diff --git a/src/main/java/com/dedicatedcode/reitti/config/TokenAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/TokenAuthenticationFilter.java similarity index 72% rename from src/main/java/com/dedicatedcode/reitti/config/TokenAuthenticationFilter.java rename to src/main/java/com/dedicatedcode/reitti/config/security/TokenAuthenticationFilter.java index 3a341fbe3..00f2a9584 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/TokenAuthenticationFilter.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/TokenAuthenticationFilter.java @@ -1,6 +1,8 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.ApiToken; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.service.ApiTokenService; import jakarta.servlet.FilterChain; @@ -33,16 +35,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } if(authHeader != null) { - Optional user = apiTokenService.getUserByToken(authHeader); + Optional tokenOpt = apiTokenService.getToken(authHeader); - if (user.isPresent()) { - User authenticatedUser = user.get().withRole(Role.API_ACCESS); - UsernamePasswordAuthenticationToken authenticationToken = - new UsernamePasswordAuthenticationToken( - authenticatedUser, - null, - authenticatedUser.getAuthorities() - ); + if (tokenOpt.isPresent()) { + ApiToken token = tokenOpt.get(); + User authenticatedUser = token.getUser().withRole(Role.API_ACCESS); + Device authenticatedDevice = token.getDevice(); + + UserDeviceAuthenticationToken authenticationToken = new UserDeviceAuthenticationToken( + authenticatedUser, + authenticatedDevice + ); trackApiTokenUsage(request, authHeader); SecurityContextHolder.getContext().setAuthentication(authenticationToken); diff --git a/src/main/java/com/dedicatedcode/reitti/config/UrlTokenAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/UrlTokenAuthenticationFilter.java similarity index 97% rename from src/main/java/com/dedicatedcode/reitti/config/UrlTokenAuthenticationFilter.java rename to src/main/java/com/dedicatedcode/reitti/config/security/UrlTokenAuthenticationFilter.java index c47ed6bd9..79a1aaad7 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/UrlTokenAuthenticationFilter.java +++ b/src/main/java/com/dedicatedcode/reitti/config/security/UrlTokenAuthenticationFilter.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.config; +package com.dedicatedcode.reitti.config.security; import com.dedicatedcode.reitti.model.Role; import com.dedicatedcode.reitti.model.security.User; diff --git a/src/main/java/com/dedicatedcode/reitti/config/security/UserDeviceAuthenticationToken.java b/src/main/java/com/dedicatedcode/reitti/config/security/UserDeviceAuthenticationToken.java new file mode 100644 index 000000000..2c6a06eda --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/config/security/UserDeviceAuthenticationToken.java @@ -0,0 +1,29 @@ +package com.dedicatedcode.reitti.config.security; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.DeviceTokenUser; +import com.dedicatedcode.reitti.model.security.User; +import org.springframework.security.authentication.AbstractAuthenticationToken; + +public class UserDeviceAuthenticationToken extends AbstractAuthenticationToken { + + private final User user; + private final Device device; + + public UserDeviceAuthenticationToken(User user, Device device) { + super(user.getAuthorities()); + this.user = user; + this.device = device; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return new DeviceTokenUser(user, device); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java b/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java index e732e41e7..d787f1bd7 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java @@ -24,13 +24,22 @@ public AvatarController(AvatarService avatarService) { @GetMapping("/{userId}") public ResponseEntity getAvatar(@PathVariable Long userId) { Optional avatarData = avatarService.getAvatarByUserId(userId); - if (avatarData.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Avatar not found"); } + return serveBinary(avatarData.get()); + } + + @GetMapping("/{userId}/{deviceId}") + public ResponseEntity getAvatar(@PathVariable Long userId, @PathVariable Long deviceId) { + Optional avatarData = avatarService.getAvatarDeviceId(userId, deviceId); + if (avatarData.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Avatar not found"); + } + return serveBinary(avatarData.get()); + } - AvatarService.AvatarData avatar = avatarData.get(); - + private static ResponseEntity serveBinary(AvatarService.AvatarData avatar) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType(avatar.mimeType())); headers.setContentLength(avatar.imageData().length); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/MapStyleControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/MapStyleControllerAdvice.java new file mode 100644 index 000000000..3fdcd7432 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/MapStyleControllerAdvice.java @@ -0,0 +1,38 @@ +package com.dedicatedcode.reitti.controller; + +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; +import com.dedicatedcode.reitti.service.MapLibreMapStylesService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +@ControllerAdvice +public class MapStyleControllerAdvice { + + private final MapLibreMapStylesService mapLibreMapStylesService; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final ObjectMapper objectMapper; + + public MapStyleControllerAdvice(MapLibreMapStylesService mapLibreMapStylesService, + UserMapStyleJdbcService userMapStyleJdbcService, + ObjectMapper objectMapper) { + this.mapLibreMapStylesService = mapLibreMapStylesService; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.objectMapper = objectMapper; + } + + @ModelAttribute("mapStylesJson") + public String getMapStylesConfiguration(@AuthenticationPrincipal User user) throws JsonProcessingException { + if (user == null) { return null; } + return this.objectMapper.writeValueAsString(this.mapLibreMapStylesService.getConfig(user)); + } + + @ModelAttribute("activeMapStyleId") + public Long getCurrentUserActiveMapStyleId(@AuthenticationPrincipal User user) { + if (user == null) { return null; } + return this.userMapStyleJdbcService.getActiveStyleId(user); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/MetadataController.java b/src/main/java/com/dedicatedcode/reitti/controller/MetadataController.java new file mode 100644 index 000000000..bb9e2c02d --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/MetadataController.java @@ -0,0 +1,124 @@ +package com.dedicatedcode.reitti.controller; + +import com.dedicatedcode.reitti.model.geo.ProcessedVisit; +import com.dedicatedcode.reitti.model.geo.Trip; +import com.dedicatedcode.reitti.model.metadata.MemoryMetadata; +import com.dedicatedcode.reitti.model.metadata.Mood; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.model.security.UserSettings; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.TripJdbcService; +import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; +import com.dedicatedcode.reitti.service.MetadataOverrideService; +import com.dedicatedcode.reitti.service.TimeUtil; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Controller +@RequestMapping("/metadata") +public class MetadataController { + private final TripJdbcService tripJdbcService; + private final ProcessedVisitJdbcService processedVisitJdbcService; + private final MetadataOverrideService metadataService; + private final UserSettingsJdbcService userSettingsJdbcService; + + public MetadataController(TripJdbcService tripJdbcService, + ProcessedVisitJdbcService processedVisitJdbcService, + MetadataOverrideService metadataService, + UserSettingsJdbcService userSettingsJdbcService) { + this.tripJdbcService = tripJdbcService; + this.processedVisitJdbcService = processedVisitJdbcService; + this.metadataService = metadataService; + this.userSettingsJdbcService = userSettingsJdbcService; + } + + @GetMapping("/{type}/{id}") + public String getMetadata(@AuthenticationPrincipal User user, + @PathVariable String type, + @PathVariable Long id, + Model model, + @RequestParam(defaultValue = "UTC") ZoneId timezone, + @RequestParam(required = false) String returnUrl) { + + UserSettings userSettings = this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()); + Optional visit = type.equals("visit") ? this.processedVisitJdbcService.findById(id) : Optional.empty(); + Optional trip = type.equals("trip") ? this.tripJdbcService.findById(id) : Optional.empty(); + Map properties = switch (type) { + case ("trip") -> trip.map(Trip::getMetadata).orElse(Collections.emptyMap()); + case ("visit") -> visit.map(ProcessedVisit::getMetadata).orElse(Collections.emptyMap()); + default -> throw new IllegalStateException("Unexpected value: " + type); + }; + + MemoryMetadata metadata = new MemoryMetadata(null, null); + metadata.setProperties(properties); + model.addAttribute("metadata", metadata); + model.addAttribute("availableMoods", Mood.values()); + model.addAttribute("returnUrl", returnUrl); + visit.ifPresent(p -> { + model.addAttribute("name", p.getPlace().getName()); + model.addAttribute("timerange", TimeUtil.formatTimeRange(p.getStartTime(), p.getEndTime(), timezone, LocalDate.now(), userSettings)); + }); + trip.ifPresent(t -> model.addAttribute("timerange", TimeUtil.formatTimeRange(t.getStartTime(), t.getEndTime(), timezone, LocalDate.now(), userSettings))); + + return "fragments/index/metadata :: metadata"; + } + + @GetMapping(value = "/suggestions/{field}", produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public List getSuggestions(@AuthenticationPrincipal User user, + @PathVariable String field, + @RequestParam String query) { + return this.metadataService.loadSuggestions(user, field, query); + } + + @PostMapping + public String updateMetadata(@AuthenticationPrincipal User user, + @RequestParam String type, + @RequestParam Long id, + @RequestParam(required = false) Mood mood, + @RequestParam(required = false) String reason, + @RequestParam(required = false) String notes, + @RequestParam(required = false) List tags, + @RequestParam(required = false) String returnUrl) { + MemoryMetadata metadata = MemoryMetadata.empty(); + metadata.setTags(tags); + metadata.setReason(reason); + metadata.setDescription(notes); + metadata.setMood(mood); + switch (type) { + case "trip" -> { + Optional trip = this.tripJdbcService.findById(id); + if (trip.isEmpty()) { + throw new IllegalArgumentException("Trip not found"); + } else { + this.metadataService.saveTripMetadata(user, trip.get(), metadata); + } + } + case "visit" -> { + Optional visit = this.processedVisitJdbcService.findById(id); + if (visit.isEmpty()) { + throw new IllegalArgumentException("Visit not found"); + } else { + this.metadataService.saveVisitMetadata(user, visit.get(), metadata); + } + } + default -> throw new IllegalStateException("Unexpected value: " + type); + } + if (returnUrl != null) { + return "redirect:" + returnUrl; + } else { + return "redirect:/"; + } + } + +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java b/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java index 8d6b9f875..ddafc4d10 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java @@ -22,8 +22,8 @@ public ReittiIntegrationController(ReittiIntegrationService reittiIntegrationSer } @GetMapping("/avatar/{integrationId}") - public ResponseEntity getAvatar(@AuthenticationPrincipal User user, @PathVariable Long integrationId) { - return this.reittiIntegrationService.getAvatar(user, integrationId).map(avatarData -> { + public ResponseEntity getAvatar(@PathVariable Long integrationId) { + return this.reittiIntegrationService.getAvatar(integrationId).map(avatarData -> { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType(avatarData.mimeType())); headers.setContentLength(avatarData.imageData().length); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java index 1c6b1d25a..79709ac5b 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java @@ -1,16 +1,12 @@ package com.dedicatedcode.reitti.controller; -import com.dedicatedcode.reitti.dto.TimelineData; -import com.dedicatedcode.reitti.dto.TimelineEntry; -import com.dedicatedcode.reitti.dto.UserTimelineData; +import com.dedicatedcode.reitti.dto.timeline.*; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.geo.TransportMode; import com.dedicatedcode.reitti.model.geo.Trip; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSettings; -import com.dedicatedcode.reitti.repository.TripJdbcService; -import com.dedicatedcode.reitti.repository.UserJdbcService; -import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; -import com.dedicatedcode.reitti.repository.UserSharingJdbcService; +import com.dedicatedcode.reitti.repository.*; import com.dedicatedcode.reitti.service.AvatarService; import com.dedicatedcode.reitti.service.TimelineService; import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService; @@ -24,6 +20,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -37,102 +34,155 @@ public class TimelineController { private final AvatarService avatarService; private final ReittiIntegrationService reittiIntegrationService; + private final DeviceJdbcService deviceJdbcService; private final UserSharingJdbcService userSharingJdbcService; private final TimelineService timelineService; private final UserSettingsJdbcService userSettingsJdbcService; private final TransportModeService transportModeService; private final TripJdbcService tripJdbcService; + private final TimelineOverviewStatisticsService timelineOverviewStatisticsService; + @Autowired public TimelineController(UserJdbcService userJdbcService, AvatarService avatarService, ReittiIntegrationService reittiIntegrationService, + DeviceJdbcService deviceJdbcService, UserSharingJdbcService userSharingJdbcService, TimelineService timelineService, UserSettingsJdbcService userSettingsJdbcService, TransportModeService transportModeService, - TripJdbcService tripJdbcService) { + TripJdbcService tripJdbcService, + TimelineOverviewStatisticsService timelineOverviewStatisticsService) { this.userJdbcService = userJdbcService; this.avatarService = avatarService; this.reittiIntegrationService = reittiIntegrationService; + this.deviceJdbcService = deviceJdbcService; this.userSharingJdbcService = userSharingJdbcService; this.timelineService = timelineService; this.userSettingsJdbcService = userSettingsJdbcService; this.transportModeService = transportModeService; this.tripJdbcService = tripJdbcService; + this.timelineOverviewStatisticsService = timelineOverviewStatisticsService; } @GetMapping("/content/range") public String getTimelineContentRange(@RequestParam LocalDate startDate, @RequestParam LocalDate endDate, - @RequestParam(required = false, defaultValue = "UTC") String timezone, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, Authentication principal, Model model) { List authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); - ZoneId userTimezone = ZoneId.of(timezone); - LocalDate now = LocalDate.now(userTimezone); + LocalDate now = LocalDate.now(timezone); - // Check if any date in the range is not today if (!startDate.isEqual(now) || !endDate.isEqual(now)) { if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) { throw new ResponseStatusException(HttpStatus.FORBIDDEN); } } + List allUsersData = new ArrayList<>(); - // Find the user by username User user = userJdbcService.findByUsername(principal.getName()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); - UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()); - // Convert LocalDate to start and end Instant for the date range in user's timezone - Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant(); - Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + UserTimelineData userData = createUserTimeLineData(user, authorities, startDate, endDate, timezone, true); + allUsersData.add(userData); - // Build timeline data for current user and connected users - List allUsersData = new ArrayList<>(); + if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) { + allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, startDate, endDate, timezone)); + allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, timezone, true)); + } - // Add current user data first - for range, we'll use the start date for the timeline service - List currentUserEntries; - if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) { - currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, startDate, startOfRange, endOfRange); + TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList()); + + model.addAttribute("timelineData", timelineData); + model.addAttribute("startDate", startDate); + model.addAttribute("endDate", endDate); + model.addAttribute("timezone", timezone); + model.addAttribute("isRange", true); + model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode()); + model.addAttribute("showUserSelection", timelineData.users().size() > 1 || timelineData.users().stream().anyMatch(data -> data.devices().size() > 1 )); + + return "fragments/timeline :: timeline-content"; + } + + private UserTimelineData createUserTimeLineData(User user, List authorities, LocalDate startDate, LocalDate endDate, ZoneId timezone, boolean loadTimeline) { + Instant startOfRange = startDate.atStartOfDay(timezone).toInstant(); + Instant endOfRange = endDate.plusDays(1).atStartOfDay(timezone).toInstant().minusMillis(1); + boolean shouldAggregate = Duration.between(startOfRange, endOfRange).toDays() > 14; + + List currentUserEntries; + if (loadTimeline && (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS"))) { + if (shouldAggregate) { + currentUserEntries = this.timelineOverviewStatisticsService.load(user, startOfRange, endOfRange, timezone); + } else { + currentUserEntries = this.timelineService.buildTimelineEntries(user, timezone, startDate, startOfRange, endOfRange, authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")); + } } else { currentUserEntries = Collections.emptyList(); } + UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()); boolean loadVisits = authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS"); boolean loadPaths = authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS") || authorities.contains("ROLE_MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS") || authorities.contains("ROLE_MAGIC_LINK_ONLY_LIVE"); - String currentUserProcessedVisitsUrl = loadVisits ? String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone) : null; - String mapMetaDataUrl = String.format("/api/v2/locations/metadata/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone); - String mapStreamDataUrl = loadPaths ? String.format("/api/v2/locations/stream/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone) : null; + String currentUserProcessedVisitsUrl = loadVisits ? String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone.getId()) : null; + String mapMetaDataUrl = String.format("/api/v2/locations/metadata/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone.getId()); + String mapStreamDataUrl = loadPaths ? String.format("/api/v2/locations/stream/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone.getId()) : null; String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId())); String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName()); - allUsersData.add(new UserTimelineData(user.getId() + "", - user.getDisplayName(), - currentUserInitials, - currentUserAvatarUrl, - userSettings.getColor(), - currentUserEntries, - null, - currentUserProcessedVisitsUrl, - mapMetaDataUrl, - mapStreamDataUrl)); + + List enabledDevices = Collections.emptyList(); + if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) { + if (this.deviceJdbcService.getAllEnabled(user).stream().filter(Device::showOnMap).count() >= 2) { + enabledDevices = this.deviceJdbcService.getAllEnabled(user).stream() + .filter(Device::showOnMap) + .map(d -> new DeviceTimelineData(d.id(), + d.name(), + this.avatarService.getAvatarDeviceId(user.getId(), d.id()).map(data -> "/avatars/" + user.getId() + "/" + d.id() + "?ts=" + data.updatedAt()).orElse(null), + this.avatarService.generateInitials(d.name()), + d.enabled(), + d.color(), + String.format("/api/v2/locations/metadata/%d/device/%d?start=%s&end=%s&timezone=%s", user.getId(), d.id(), startDate, endDate, timezone.getId()), + loadPaths ? String.format("/api/v2/locations/stream/%d/device/%d?start=%s&end=%s&timezone=%s", user.getId(), d.id(),startDate, endDate, timezone.getId()) : null)) + .toList(); + } + } + return new UserTimelineData(user.getId() + "", + user.getDisplayName(), + currentUserInitials, + currentUserAvatarUrl, + userSettings.getColor(), + currentUserEntries, + null, + currentUserProcessedVisitsUrl, + mapMetaDataUrl, + mapStreamDataUrl, + enabledDevices); + } + + @GetMapping("/user-selection") + public String loadUserSelection(@RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Authentication principal, + Model model) { + List authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); + List allUsersData = new ArrayList<>(); + User user = userJdbcService.findByUsername(principal.getName()).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + UserTimelineData userData = createUserTimeLineData(user, authorities, startDate, endDate, timezone, false); + allUsersData.add(userData); if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) { - allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, startDate, endDate, userTimezone)); - allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, userTimezone)); + allUsersData.addAll(this.reittiIntegrationService.getUserData(user, startDate, endDate, timezone)); + allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, timezone, false)); } TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList()); - model.addAttribute("timelineData", timelineData); - model.addAttribute("startDate", startDate); - model.addAttribute("endDate", endDate); - model.addAttribute("timezone", timezone); - model.addAttribute("isRange", true); - model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode()); - return "fragments/timeline :: timeline-content"; + model.addAttribute("showUserSelection", timelineData.users().size() > 1); + return "fragments/user-selection :: user-selection"; } @GetMapping("/trips/edit-form/{id}") @@ -178,15 +228,15 @@ public String getTripView(@PathVariable Long id, Model model) { return "fragments/trip-edit :: view-mode"; } - private List handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) { + private List handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone, boolean loadTimeline) { return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream() .map(u -> { Optional sharedWithUserOpt = this.userJdbcService.findById(u.getSharingUserId()); return sharedWithUserOpt.map(sharedWithUser -> { Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant(); Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); - - List userTimelineEntries = this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, startDate, startOfRange, endOfRange); + + List userTimelineEntries = loadTimeline ? this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, startDate, startOfRange, endOfRange, false) : Collections.emptyList(); String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId()); String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId()); String mapMetaDataUrl = String.format("/api/v2/locations/metadata/%d?start=%s&end=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId()); @@ -203,7 +253,8 @@ private List handleSharedUserDataRange(User user, LocalDate st currentUserRawLocationPointsUrl, currentUserProcessedVisitsUrl, mapMetaDataUrl, - mapStreamDataUrl); + mapStreamDataUrl, + Collections.emptyList()); }); }) .filter(Optional::isPresent) diff --git a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java index 4faaef52c..1d141d7eb 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java @@ -28,6 +28,7 @@ public class UserSettingsControllerAdvice { public static final double DEFAULT_HOME_LATITUDE = 60.1699; public static final double DEFAULT_HOME_LONGITUDE = 24.9384; + private static final String DEFAULT_COLOR = "#F5DEB3FF"; private final UserJdbcService userJdbcService; private final UserSettingsJdbcService userSettingsJdbcService; private final TilesCustomizationProvider tilesCustomizationProvider; @@ -49,8 +50,7 @@ public UserSettingsDTO getCurrentUserSettings() { if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { // Return default settings for anonymous users - return new UserSettingsDTO(false, - Language.EN, + return new UserSettingsDTO(Language.EN, Locale.ENGLISH.toLanguageTag(), Instant.now(), UnitSystem.METRIC, @@ -62,7 +62,9 @@ public UserSettingsDTO getCurrentUserSettings() { TimeDisplayMode.DEFAULT, TimeMode.TWENTY_FOUR_HOUR, null, - null); + null, + DEFAULT_COLOR + ); } String username = authentication.getName(); @@ -77,8 +79,7 @@ public UserSettingsDTO getCurrentUserSettings() { latestData = rawLocationPointJdbcService.findLatest(user).map(RawLocationPoint::getTimestamp).orElse(null); } Language selectedLanguage = dbSettings.getSelectedLanguage(); - return new UserSettingsDTO(dbSettings.isPreferColoredMap(), - selectedLanguage, + return new UserSettingsDTO(selectedLanguage, selectedLanguage.getLocale().toLanguageTag(), latestData, dbSettings.getUnitSystem(), @@ -90,11 +91,11 @@ public UserSettingsDTO getCurrentUserSettings() { dbSettings.getTimeDisplayMode(), dbSettings.getTimeMode(), dbSettings.getTimeZoneOverride(), - dbSettings.getCustomCss() !=null ? "/user-css/" + user.getId() : null); + dbSettings.getCustomCss() !=null ? "/user-css/" + user.getId() : null, + dbSettings.getColor()); } // Fallback for authenticated users not found in database - return new UserSettingsDTO(false, - Language.EN, + return new UserSettingsDTO(Language.EN, Locale.ENGLISH.toLanguageTag(), Instant.now(), UnitSystem.METRIC, @@ -106,9 +107,12 @@ public UserSettingsDTO getCurrentUserSettings() { TimeDisplayMode.DEFAULT, TimeMode.TWENTY_FOUR_HOUR, null, - null); + null, + DEFAULT_COLOR); + } + private UserSettingsDTO.UIMode mapUserToUiMode(Authentication authentication) { List grantedRoles = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); if (grantedRoles.contains("ROLE_ADMIN") || grantedRoles.contains("ROLE_USER") || grantedRoles.contains("ROLE_API_ACCESS")) { diff --git a/src/main/java/com/dedicatedcode/reitti/controller/WorkbenchController.java b/src/main/java/com/dedicatedcode/reitti/controller/WorkbenchController.java new file mode 100644 index 000000000..b42f67a17 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/WorkbenchController.java @@ -0,0 +1,35 @@ +package com.dedicatedcode.reitti.controller; + +import com.dedicatedcode.reitti.dto.workbench.WorkbenchCommitRequest; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/workbench") +public class WorkbenchController { + + private final DeviceJdbcService deviceJdbcService; + private final boolean dataManagementEnabled; + + public WorkbenchController(DeviceJdbcService deviceJdbcService, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { + this.deviceJdbcService = deviceJdbcService; + this.dataManagementEnabled = dataManagementEnabled; + } + + @GetMapping + public String workbench(@AuthenticationPrincipal User user, Model model) { + model.addAttribute("devices", this.deviceJdbcService.getAll(user).stream().filter(Device::enabled).toList()); + model.addAttribute("dataManagementEnabled", dataManagementEnabled); + + return "workbench"; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/GeoJsonApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/GeoJsonApiController.java new file mode 100644 index 000000000..7f38dc4f7 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/GeoJsonApiController.java @@ -0,0 +1,127 @@ +package com.dedicatedcode.reitti.controller.api; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import com.dedicatedcode.reitti.service.GeoJsonExportService; +import com.dedicatedcode.reitti.service.importer.GeoJsonImporter; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/geojson") +public class GeoJsonApiController { + + private final GeoJsonExportService geoJsonExportService; + private final DeviceJdbcService deviceJdbcService; + private final GeoJsonImporter geoJsonImporter; + + public GeoJsonApiController(GeoJsonExportService geoJsonExportService, DeviceJdbcService deviceJdbcService, + GeoJsonImporter geoJsonImporter) { + this.geoJsonExportService = geoJsonExportService; + this.deviceJdbcService = deviceJdbcService; + this.geoJsonImporter = geoJsonImporter; + } + + @GetMapping(value = "/export", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity exportGeoJson( + @AuthenticationPrincipal User user, + @RequestParam("start") LocalDate start, + @RequestParam("end") LocalDate end, + @RequestParam(value = "device", required = false) Long deviceId) { + + try { + StreamingResponseBody stream = outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + geoJsonExportService.generateGeoJsonContentStreaming( + user, + ZonedDateTime.of(start.atStartOfDay(), ZoneId.of("UTC")).toInstant(), + ZonedDateTime.of(end.atStartOfDay(), ZoneId.of("UTC")).toInstant(), + deviceId, + writer); + } catch (Exception e) { + throw new RuntimeException("Error generating GeoJSON file", e); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(stream); + + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + writer.write("Error generating GeoJSON file: " + e.getMessage()); + } catch (IOException ioException) { + throw new RuntimeException(ioException); + } + }); + } + } + + @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> importGeoJson( + @AuthenticationPrincipal User user, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "device", required = false) Long deviceId) { + + Map response = new HashMap<>(); + + Device device = this.deviceJdbcService.find(user, deviceId).orElse(null); + try { + if (file.isEmpty() || file.getOriginalFilename() == null) { + response.put("success", false); + response.put("error", "File is empty"); + return ResponseEntity.badRequest().body(response); + } + + String filename = file.getOriginalFilename(); + if (!filename.endsWith(".geojson") && !filename.endsWith(".json")) { + response.put("success", false); + response.put("error", "Only GeoJSON files (.geojson or .json) are supported"); + return ResponseEntity.badRequest().body(response); + } + + try (InputStream inputStream = file.getInputStream()) { + Map result = geoJsonImporter.importGeoJson( + inputStream, user, device, filename); + + if ((Boolean) result.get("success")) { + response.put("success", true); + response.put("pointsScheduled", result.get("pointsImported")); + response.put("message", "Successfully imported GeoJSON file with " + + result.get("pointsImported") + " location points"); + } else { + response.put("success", false); + response.put("error", result.get("error")); + } + return ResponseEntity.ok(response); + } + } catch (IOException e) { + response.put("success", false); + response.put("error", "Error processing file: " + e.getMessage()); + return ResponseEntity.status(500).body(response); + } catch (Exception e) { + response.put("success", false); + response.put("error", "Unexpected error: " + e.getMessage()); + return ResponseEntity.status(500).body(response); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java index 64f259f72..0fe0c9f2b 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java @@ -1,6 +1,9 @@ package com.dedicatedcode.reitti.controller.api; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.DeviceTokenUser; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; import com.dedicatedcode.reitti.service.GpxExportService; import com.dedicatedcode.reitti.service.importer.GpxImporter; import org.springframework.http.MediaType; @@ -24,27 +27,37 @@ @RestController @RequestMapping("/api/v1/gpx") public class GpxApiController { - + + private final DeviceJdbcService deviceJdbcService; private final GpxExportService gpxExportService; private final GpxImporter gpxImporter; - public GpxApiController(GpxExportService gpxExportService, GpxImporter gpxImporter) { + public GpxApiController(DeviceJdbcService deviceJdbcService, + GpxExportService gpxExportService, + GpxImporter gpxImporter) { + this.deviceJdbcService = deviceJdbcService; this.gpxExportService = gpxExportService; this.gpxImporter = gpxImporter; } @GetMapping("/export") - public ResponseEntity exportGpx(@AuthenticationPrincipal User user, + public ResponseEntity exportGpx(@AuthenticationPrincipal DeviceTokenUser user, + @RequestParam(required = false) Long device, @RequestParam LocalDate start, @RequestParam LocalDate end) { try { + Device requestedDevice = device == null ? user.getDevice().orElse(null) : this.deviceJdbcService.find(user, device).orElseThrow(IllegalArgumentException::new); + if (requestedDevice == null) { + throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it."); + } StreamingResponseBody stream = outputStream -> { try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { gpxExportService.generateGpxContentStreaming(user, - ZonedDateTime.of(start.atStartOfDay(), ZoneId.of("UTC")).toInstant(), - ZonedDateTime.of(end.atStartOfDay(), ZoneId.of("UTC")).toInstant(), - writer, - false); + requestedDevice, + ZonedDateTime.of(start.atStartOfDay(), ZoneId.of("UTC")).toInstant(), + ZonedDateTime.of(end.atStartOfDay(), ZoneId.of("UTC")).toInstant(), + writer, + false); } catch (Exception e) { throw new RuntimeException("Error generating GPX file", e); } @@ -65,8 +78,10 @@ public ResponseEntity exportGpx(@AuthenticationPrincipal }); } } + @PostMapping("/import") - public ResponseEntity> importGpx(@AuthenticationPrincipal User user, + public ResponseEntity> importGpx(@AuthenticationPrincipal DeviceTokenUser user, + @RequestParam(required = false) Long device, @RequestParam("file") MultipartFile file) { Map response = new HashMap<>(); @@ -82,9 +97,23 @@ public ResponseEntity> importGpx(@AuthenticationPrincipal Us response.put("error", "Only GPX files are supported"); return ResponseEntity.badRequest().body(response); } - + Device requestedDevice; + if (device == null) { + requestedDevice = user.getDevice().orElse(null); + } else { + requestedDevice = this.deviceJdbcService.find(user, device).orElse(null); + if (requestedDevice == null) { + response.put("success", false); + response.put("error", "Requested device not found"); + return ResponseEntity.badRequest().body(response); + } + } + if (requestedDevice == null) { + response.put("error", "Token has no device attached. Please use another token or attach a device to it."); + return ResponseEntity.badRequest().body(response); + } try (InputStream inputStream = file.getInputStream()) { - Map result = gpxImporter.importGpx(inputStream, user); + Map result = gpxImporter.importGpx(inputStream, user, requestedDevice, file.getOriginalFilename()); if ((Boolean) result.get("success")) { response.put("success", true); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java index a3d39f87b..fe775a34a 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java @@ -191,7 +191,7 @@ private List> loadSegmentsInBoundingBoxAndTime(User user, Do List pointsInBoxWithNeighbors; long start = System.nanoTime(); if (Duration.between(startOfRange, endOfRange).toDays() > 30 && minLat == null && maxLat == null && minLng == null && maxLng == null) { - pointsInBoxWithNeighbors = rawLocationPointJdbcService.findSimplifiedRouteForPeriod(user, startOfRange, endOfRange, 10000); + pointsInBoxWithNeighbors = rawLocationPointJdbcService.findSimplifiedRouteForPeriod(user, startOfRange, endOfRange); } else if (minLat == null || maxLat == null || minLng == null || maxLng == null) { pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange); logger.trace("Loaded points in time range from database in [{}]ms", (System.nanoTime() - start) / 1_000_000); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java deleted file mode 100644 index 9b0442801..000000000 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.dedicatedcode.reitti.controller.api; - -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; -import com.dedicatedcode.reitti.service.ContextPathHolder; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.TextNode; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.CacheControl; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.core.io.ClassPathResource; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -@RestController -@RequestMapping("/map") -public class MapStyleController { - - private final ObjectMapper objectMapper; - private final ContextPathHolder contextPathHolder; - private final UserSettingsJdbcService userSettingsJdbcService; - private final boolean tileCacheEnabled; - - public MapStyleController( - ObjectMapper objectMapper, - ContextPathHolder contextPathHolder, - UserSettingsJdbcService userSettingsJdbcService, - @Value("${reitti.ui.tiles.cache.url:}") String cacheUrl) { - this.objectMapper = objectMapper; - this.contextPathHolder = contextPathHolder; - this.userSettingsJdbcService = userSettingsJdbcService; - this.tileCacheEnabled = StringUtils.hasText(cacheUrl); - } - - @GetMapping(value = "/reitti.json", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getStyle(@AuthenticationPrincipal User user, HttpServletRequest request) throws IOException { - ClassPathResource resource = new ClassPathResource("static/map/reitti.json"); - ClassPathResource coloredResource = new ClassPathResource("static/map/colored.json"); - - JsonNode style; - if (this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).isPreferColoredMap()) { - style = objectMapper.readTree(coloredResource.getInputStream()); - } else { - style = objectMapper.readTree(resource.getInputStream()); - } - - if (this.tileCacheEnabled) { - style = rewriteUrlsForProxy(style, request); - } - - if (!this.contextPathHolder.getContextPath().equals("/")) { - style = rewriteResourceUrls(style, request); - } - - return ResponseEntity.ok() - .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)) - .body(style); - } - - private JsonNode rewriteResourceUrls(JsonNode style, HttpServletRequest request) { - ObjectNode mutableStyle = style.deepCopy(); - // Rewrite sources - TextNode glyphs = (TextNode) mutableStyle.get("glyphs"); - mutableStyle.set("glyphs", new TextNode(this.contextPathHolder.getContextPath() + glyphs.asText())); - return mutableStyle; - } - - private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request) { - ObjectNode mutableStyle = style.deepCopy(); - String baseUrl = getBaseUrl(request); - - // Rewrite sources - JsonNode sources = mutableStyle.get("sources"); - if (sources != null) { - ObjectNode mutableSources = (ObjectNode) sources; - - // Handle vector tiles (OpenFreeMap) - if (mutableSources.has("openmaptiles")) { - ObjectNode openMapTiles = (ObjectNode) mutableSources.get("openmaptiles"); - openMapTiles.remove("url"); - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf"); - openMapTiles.set("tiles", tiles); - } - - // Handle raster sources - rewriteRasterSource(mutableSources, "terrain-source", baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp"); - rewriteRasterSource(mutableSources, "satellite-source", baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg"); - } - - return mutableStyle; - } - - private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) { - if (sources.has(sourceName)) { - ObjectNode source = (ObjectNode) sources.get(sourceName); - ArrayNode tiles = objectMapper.createArrayNode(); - tiles.add(tileUrl); - source.set("tiles", tiles); - } - } - - private String getBaseUrl(HttpServletRequest request) { - String scheme = request.getScheme(); - String serverName = request.getServerName(); - int serverPort = request.getServerPort(); - String contextPath = request.getContextPath(); - - StringBuilder url = new StringBuilder(); - url.append(scheme).append("://").append(serverName); - - if ((scheme.equals("http") && serverPort != 80) || - (scheme.equals("https") && serverPort != 443)) { - url.append(":").append(serverPort); - } - - url.append(contextPath); - return url.toString(); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java index 2c91272bb..ea7cf2a74 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java @@ -1,6 +1,6 @@ package com.dedicatedcode.reitti.controller.api; -import com.dedicatedcode.reitti.dto.TimelineEntry; +import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.service.TimelineService; import com.dedicatedcode.reitti.service.VisitDetectionPreviewService; @@ -36,16 +36,16 @@ public ResponseEntity> getPreviewStatus(@PathVariable String } @GetMapping("/{previewId}/timeline") - public List getPreviewTimeline(@AuthenticationPrincipal User user, - @PathVariable String previewId, - @RequestParam String date, - @RequestParam(required = false, defaultValue = "UTC") String timezone) { + public List getPreviewTimeline(@AuthenticationPrincipal User user, + @PathVariable String previewId, + @RequestParam String date, + @RequestParam(required = false, defaultValue = "UTC") String timezone) { LocalDate selectedDate = LocalDate.parse(date); ZoneId userTimezone = ZoneId.of(timezone); Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant(); Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant(); - return this.timelineService.buildTimelineEntries(user, previewId, userTimezone, selectedDate, startOfDay, endOfDay); + return this.timelineService.buildTimelineEntries(user, previewId, userTimezone, selectedDate, startOfDay, endOfDay, false); } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/QueueStatsApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/QueueStatsApiController.java deleted file mode 100644 index 26dbe0c1a..000000000 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/QueueStatsApiController.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.dedicatedcode.reitti.controller.api; - -import com.dedicatedcode.reitti.service.QueueStats; -import com.dedicatedcode.reitti.service.QueueStatsService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/queue-stats") -public class QueueStatsApiController { - - private final QueueStatsService queueStatsService; - - public QueueStatsApiController(QueueStatsService queueStatsService) { - this.queueStatsService = queueStatsService; - } - - @GetMapping - public ResponseEntity> getQueueStats() { - return ResponseEntity.ok(queueStatsService.getQueueStats()); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java index a7e523f13..99070de70 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java @@ -3,7 +3,7 @@ import com.dedicatedcode.reitti.dto.ReittiRemoteInfo; import com.dedicatedcode.reitti.dto.SubscriptionRequest; import com.dedicatedcode.reitti.dto.SubscriptionResponse; -import com.dedicatedcode.reitti.dto.TimelineEntry; +import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry; import com.dedicatedcode.reitti.model.NotificationData; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.UserJdbcService; @@ -60,10 +60,10 @@ public ResponseEntity getInfo(@AuthenticationPrincipal User us } @GetMapping("/timeline") - public List getTimeline(@AuthenticationPrincipal User user, - @RequestParam String startDate, - @RequestParam String endDate, - @RequestParam(required = false, defaultValue = "UTC") String timezone) { + public List getTimeline(@AuthenticationPrincipal User user, + @RequestParam String startDate, + @RequestParam String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone) { ZoneId userTimezone = ZoneId.of(timezone); @@ -73,7 +73,7 @@ public List getTimeline(@AuthenticationPrincipal User user, Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant(); Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); - return this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange); + return this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange, false); } @PostMapping("/subscribe") diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java index 838f21886..31ab3a346 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -1,6 +1,17 @@ package com.dedicatedcode.reitti.controller.api; -import com.dedicatedcode.reitti.config.ConditionalOnPropertyNotEmpty; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; +import com.dedicatedcode.reitti.service.ContextPathHolder; +import com.dedicatedcode.reitti.service.MapLibreMapStylesService; +import com.dedicatedcode.reitti.service.RequestHelper; +import com.dedicatedcode.reitti.service.TileUrlUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -9,43 +20,65 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; @RestController @RequestMapping("/api/v1/tiles") -@ConditionalOnPropertyNotEmpty("reitti.ui.tiles.cache.url") public class TileProxyController { private static final Logger log = LoggerFactory.getLogger(TileProxyController.class); + private static final String CUSTOM_UPSTREAM_HEADER = "X-Reitti-Upstream-Url"; private final HttpClient httpClient; private final String tileCacheUrl; + private final boolean tileCacheEnabled; + private final String defaultTileService; + private final String customTileService; + private final ObjectMapper objectMapper; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final MapLibreMapStylesService mapLibreMapStylesService; + private final ContextPathHolder contextPathHolder; - // Maps source names to internal paths and coordinate ordering - private record SourceConfig(String path, boolean swapXY, String contentType) {} - - private static final Map SOURCES = Map.of( - "raster", new SourceConfig("/osm/", false, MediaType.IMAGE_PNG_VALUE), - "osm", new SourceConfig("/osm/", false, "application/x-protobuf"), - "vector", new SourceConfig("/vector/", false, "application/x-protobuf"), - "terrain", new SourceConfig("/terrain/", false, "image/webp"), - "satellite", new SourceConfig("/satellite/", true, "image/jpeg") - ); - - public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl) { + private record TileSource(String tileJsonUrl, List tileUrlTemplates, boolean proxyTiles) {} + + public TileProxyController( + @Value("${reitti.ui.tiles.cache.url:}") String tileCacheUrl, + @Value("${reitti.ui.tiles.default.service}") String defaultTileService, + @Value("${reitti.ui.tiles.custom.service:}") String customTileService, + ObjectMapper objectMapper, + UserMapStyleJdbcService userMapStyleJdbcService, + MapLibreMapStylesService mapLibreMapStylesService, + ContextPathHolder contextPathHolder) { this.tileCacheUrl = tileCacheUrl; + this.tileCacheEnabled = StringUtils.hasText(tileCacheUrl); + this.defaultTileService = defaultTileService; + this.customTileService = customTileService; + this.objectMapper = objectMapper; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.mapLibreMapStylesService = mapLibreMapStylesService; + this.contextPathHolder = contextPathHolder; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) .build(); } @@ -53,60 +86,303 @@ public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCac public ResponseEntity getTileLegacy( @PathVariable int z, @PathVariable int x, - @PathVariable int y, + @PathVariable int y) { + + String template = StringUtils.hasText(customTileService) ? customTileService : defaultTileService; + if (!StringUtils.hasText(template)) { + return ResponseEntity.notFound().build(); + } + String upstreamTileUrl = template + .replace("{z}", String.valueOf(z)) + .replace("{x}", String.valueOf(x)) + .replace("{y}", String.valueOf(y)); + URI upstreamTileUri = URI.create(upstreamTileUrl); + log.trace("Fetching custom tile: {}", upstreamTileUri); + + if (this.tileCacheEnabled) { + String tileUrl = tileCacheUrl + "/custom/"; + return fetchTile(tileUrl, MediaType.IMAGE_PNG_VALUE, "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl)); + } else { + return fetchTile(upstreamTileUrl, MediaType.IMAGE_PNG_VALUE, "custom"); + } + } + + @GetMapping("/styles/{styleId}/style.json") + public ResponseEntity getStyleJson( + @AuthenticationPrincipal User user, + @PathVariable Long styleId) { + + try { + JsonNode styleJson = mapLibreMapStylesService.getCompleteStyleJson(styleId, user); + if (styleJson == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(styleJson); + } catch (Exception e) { + log.warn("Failed to serve style JSON [{}]: {}", styleId, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/styles/{styleId}/{sourceId}/tilejson.json") + public ResponseEntity getStyleSourceTileJson( + @AuthenticationPrincipal User user, + @PathVariable Long styleId, + @PathVariable String sourceId, HttpServletRequest request) { - return getTile("raster", z, x, y, "png", request); + + try { + boolean proxyTiles = isProxyTilesEnabled(user, styleId); + Optional source = resolveTileSource(user, styleId, sourceId, proxyTiles); + if (source.isEmpty()) { + return ResponseEntity.notFound().build(); + } + TileSource tileSource = source.get(); + + // Case 1: We have a TileJSON URL – fetch it and rewrite the tiles array + if (tileSource.tileJsonUrl() != null && !tileSource.tileJsonUrl().isBlank()) { + String tileJsonUrl = tileSource.tileJsonUrl(); + if (!tileSource.proxyTiles()) { + return ResponseEntity.notFound().build(); + } + URI tileJsonUri = URI.create(tileJsonUrl); + + HttpResponse response = fetchRaw(tileJsonUrl, Map.of()); + if (response.statusCode() != 200) { + log.debug("Failed to fetch custom TileJSON [{}]: HTTP {}", tileJsonUri, response.statusCode()); + return ResponseEntity.notFound().build(); + } + + JsonNode tileJson = objectMapper.readTree(responseBody(response)); + if (tileJson instanceof ObjectNode mutableTileJson && mutableTileJson.get("tiles") instanceof ArrayNode tiles && !tiles.isEmpty()) { + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + for (JsonNode tileNode : tiles) { + String tileUrl = tileNode.asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + rewrittenTiles.add(styleSourceTileUrl(styleId, sourceId, tileUrl, request)); + } else if (!tileUrl.isBlank()) { + String resolvedTileUrl = tileJsonUri.resolve(tileUrl).toString(); + rewrittenTiles.add(styleSourceTileUrl(styleId, sourceId, resolvedTileUrl, request)); + } else { + rewrittenTiles.add(tileUrl); + } + } + mutableTileJson.set("tiles", rewrittenTiles); + } + + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(tileJson); + } + + // Case 2: We have a tile URL template directly – build a TileJSON on the fly + if (!tileSource.tileUrlTemplates().isEmpty()) { + String template = tileSource.tileUrlTemplates().getFirst(); + ObjectNode tileJson = objectMapper.createObjectNode(); + tileJson.put("tilejson", "2.2.0"); + tileJson.put("name", "Custom Tiles"); + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(styleSourceTileUrl(styleId, sourceId, template, request)); + tileJson.set("tiles", tiles); + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().cachePrivate()) + .body(tileJson); + } + + // No valid source configuration found + return ResponseEntity.notFound().build(); + } catch (Exception e) { + log.warn("Failed to fetch custom TileJSON [{}/{}]: {}", styleId, sourceId, e.getMessage()); + return ResponseEntity.notFound().build(); + } } - @GetMapping("/{source}/{z}/{x}/{y}.{ext}") - public ResponseEntity getTile( - @PathVariable String source, + @GetMapping("/styles/{styleId}/{sourceId}/{z}/{x}/{y}.{ext}") + public ResponseEntity getStyleSourceTile( + @AuthenticationPrincipal User user, + @PathVariable Long styleId, + @PathVariable String sourceId, @PathVariable int z, @PathVariable int x, @PathVariable int y, - @PathVariable String ext, - HttpServletRequest request) { + @PathVariable String ext) { + + try { + boolean proxyTiles = isProxyTilesEnabled(user, styleId); + Optional source = resolveTileSource(user, styleId, sourceId, proxyTiles); + if (source.isEmpty()) { + return ResponseEntity.notFound().build(); + } + String template = tileTemplate(source.get()); + if (!StringUtils.hasText(template)) { + return ResponseEntity.notFound().build(); + } + if (!source.get().proxyTiles()) { + return ResponseEntity.notFound().build(); + } + String upstreamTileUrl = template + .replace("{z}", String.valueOf(z)) + .replace("{x}", String.valueOf(x)) + .replace("{y}", String.valueOf(y)) + .replace("{r}", ""); - SourceConfig config = SOURCES.get(source); - if (config == null) { + URI upstreamTileUri = URI.create(upstreamTileUrl); + log.trace("Fetching custom tile [{}/{}]: {}", styleId, sourceId, upstreamTileUri); + + if (this.tileCacheEnabled) { + String tileUrl = tileCacheUrl + "/custom/"; + return fetchTile(tileUrl, contentTypeForExtension(ext), "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl)); + } else { + return fetchTile(upstreamTileUrl, contentTypeForExtension(ext), "custom"); + } + } catch (IllegalArgumentException e) { + log.warn("Failed to resolve custom tile [{}/{}]: {}", styleId, sourceId, e.getMessage()); + return ResponseEntity.badRequest().build(); + } catch (Exception e) { + log.warn("Failed to fetch custom tile [{}/{}]: {}", styleId, sourceId, e.getMessage()); return ResponseEntity.notFound().build(); } + } - try { - String coordPath = config.swapXY() - ? String.format("%d/%d/%d", z, y, x) - : String.format("%d/%d/%d.%s", z, x, y, ext); - String tileUrl = tileCacheUrl + config.path() + coordPath; - log.trace("Fetching tile [{}]: {}", source, coordPath); + private boolean isProxyTilesEnabled(User user, Long styleId) { + try { + Optional style = userMapStyleJdbcService.findById(user, styleId); + if (style.isPresent()) { + MapStyleDataSource source = style.get().dataSource(); + if (source != null) { + return source.proxyTiles(); + } + } + } catch (NumberFormatException ignored) {} + return true; + } - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(tileUrl)) - .timeout(Duration.ofSeconds(30)) - .GET(); + private ResponseEntity fetchTile(String tileUrl, String contentType, String source) { + return fetchTile(tileUrl, contentType, source, Map.of()); + } - HttpResponse response = httpClient.send( - requestBuilder.build(), - HttpResponse.BodyHandlers.ofByteArray() - ); + private ResponseEntity fetchTile(String tileUrl, String contentType, String source, Map requestHeaders) { + try { + HttpResponse response = fetchRaw(tileUrl, requestHeaders); if (response.statusCode() == 200) { HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType(config.contentType())); + headers.setContentType(MediaType.parseMediaType(contentType)); headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); headers.add("Access-Control-Allow-Origin", "*"); - + response.headers() + .firstValue(HttpHeaders.CONTENT_ENCODING) + .ifPresent(contentEncoding -> headers.add(HttpHeaders.CONTENT_ENCODING, contentEncoding)); + return ResponseEntity.ok() .headers(headers) .body(response.body()); } else { - log.debug("Failed to fetch tile {}/{}/{} from {}: HTTP {}", x, y, z, source, response.statusCode()); + log.debug("Failed to fetch tile from {}: HTTP {}", source, response.statusCode()); return ResponseEntity.notFound().build(); } - } catch (Exception e) { - log.warn("Failed to fetch tile {}/{}/{} from {}: {}", x, y, z, source, e.getMessage()); + log.warn("Failed to fetch tile from {}: {}", source, e.getMessage()); return ResponseEntity.notFound().build(); } } + + private HttpResponse fetchRaw(String url, Map extraHeaders) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .header("User-Agent", "Reitti/1.0 (+https://github.com/dedicatedcode/reitti; contact: reitti@dedicatedcode.com)") + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") + .GET(); + extraHeaders.forEach(requestBuilder::header); + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + } + + private byte[] responseBody(HttpResponse response) throws IOException { + String contentEncoding = response.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse(""); + if (contentEncoding.equalsIgnoreCase("gzip")) { + try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { + return gzipInputStream.readAllBytes(); + } + } + if (contentEncoding.equalsIgnoreCase("deflate")) { + try (InflaterInputStream inflaterInputStream = new InflaterInputStream(new ByteArrayInputStream(response.body()))) { + return inflaterInputStream.readAllBytes(); + } + } + return response.body(); + } + + private Optional resolveTileSource(User user, Long styleId, String sourceId, boolean proxyTiles) { + // First, try to get original TileJSON URL (upstream tilejson) + String tileJsonUrl = mapLibreMapStylesService.getOriginalTileJsonUrl(styleId, sourceId, user); + if (tileJsonUrl != null && !tileJsonUrl.isBlank()) { + return Optional.of(new TileSource(tileJsonUrl, List.of(), proxyTiles)); + } + // Fallback to tile URL template + String originalTileUrl = mapLibreMapStylesService.getOriginalTileUrl(styleId, sourceId, user); + if (originalTileUrl != null) { + List templates = new ArrayList<>(); + templates.add(normalizeTileTemplateForProxy(originalTileUrl)); + return Optional.of(new TileSource(null, templates, proxyTiles)); + } + throw new IllegalArgumentException("No original tile URL found for style " + styleId + " and source " + sourceId); + } + + private String tileTemplate(TileSource source) throws Exception { + if (!source.tileUrlTemplates().isEmpty()) { + return source.tileUrlTemplates().getFirst(); + } + + if (!StringUtils.hasText(source.tileJsonUrl())) { + return null; + } + + String tileJsonUrl = source.tileJsonUrl(); + URI tileJsonUri = URI.create(tileJsonUrl); + + HttpResponse response = fetchRaw(tileJsonUrl, Map.of()); + if (response.statusCode() != 200) { + throw new IOException("Failed to fetch TileJSON: " + response.statusCode()); + } + + JsonNode tileJson = objectMapper.readTree(responseBody(response)); + + JsonNode tiles = tileJson.get("tiles"); + if (!(tiles instanceof ArrayNode tileArray) || tileArray.isEmpty()) { + return null; + } + + String tileUrl = tileArray.get(0).asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + return normalizeTileTemplateForProxy(tileUrl); + } + if (StringUtils.hasText(tileUrl)) { + return normalizeTileTemplateForProxy(tileJsonUri.resolve(tileUrl).toString()); + } + return null; + } + + private String styleSourceTileUrl(Long styleId, String sourceId, String tileUrl, HttpServletRequest request) { + String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl); + return RequestHelper.getBaseUrl(request) + contextPathHolder.getContextPath() + "/api/v1/tiles/styles/" + styleId + "/" + sourceId + + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl); + } + + private String normalizeTileTemplateForProxy(String tileUrl) { + return tileUrl.replace("{r}", ""); + } + + private String contentTypeForExtension(String ext) { + return switch (ext.toLowerCase()) { + case "pbf", "mvt" -> "application/x-protobuf"; + case "png" -> MediaType.IMAGE_PNG_VALUE; + case "jpg", "jpeg" -> MediaType.IMAGE_JPEG_VALUE; + case "webp" -> "image/webp"; + default -> MediaType.APPLICATION_OCTET_STREAM_VALUE; + }; + } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java index c9d2021d0..1ba86bce5 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java @@ -1,6 +1,6 @@ package com.dedicatedcode.reitti.controller.api; -import com.dedicatedcode.reitti.dto.TimelineEntry; +import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.service.TimelineService; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -25,9 +25,9 @@ public TimelineApiController(TimelineService timelineService) { } @GetMapping - public List getTimeline(@AuthenticationPrincipal User user, - @RequestParam String date, - @RequestParam(required = false, defaultValue = "UTC") String timezone) { + public List getTimeline(@AuthenticationPrincipal User user, + @RequestParam String date, + @RequestParam(required = false, defaultValue = "UTC") String timezone) { LocalDate selectedDate = LocalDate.parse(date); ZoneId userTimezone = ZoneId.of(timezone); @@ -35,7 +35,7 @@ public List getTimeline(@AuthenticationPrincipal User user, Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant(); Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); - return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay); + return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay, true); } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java index b029949dc..543a02bd9 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java @@ -3,17 +3,13 @@ import com.dedicatedcode.reitti.controller.api.ingestion.owntracks.OwntracksFriendResponse; import com.dedicatedcode.reitti.dto.LocationPoint; import com.dedicatedcode.reitti.dto.OwntracksLocationRequest; -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.UserJdbcService; +import com.dedicatedcode.reitti.model.security.DeviceTokenUser; import com.dedicatedcode.reitti.service.LocationBatchingService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,21 +23,17 @@ @RequestMapping("/api/v1/ingest") public class GPSLoggerIngestionApiController { private static final Logger logger = LoggerFactory.getLogger(GPSLoggerIngestionApiController.class); - private final UserJdbcService userJdbcService; private final LocationBatchingService locationBatchingService; - public GPSLoggerIngestionApiController(UserJdbcService userJdbcService, - LocationBatchingService locationBatchingService) { - this.userJdbcService = userJdbcService; + public GPSLoggerIngestionApiController(LocationBatchingService locationBatchingService) { this.locationBatchingService = locationBatchingService; } @PostMapping("/gpslogger") - public ResponseEntity receiveData(@RequestBody OwntracksLocationRequest request) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername())); - + public ResponseEntity receiveData(@AuthenticationPrincipal DeviceTokenUser user, @RequestBody OwntracksLocationRequest request) { + if (user.getDevice().isEmpty()) { + throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it."); + } try { if (!request.isLocationUpdate()) { logger.debug("Ignoring non-location GpsLogger message of type: {}", request.getType()); @@ -58,7 +50,7 @@ public ResponseEntity receiveData(@RequestBody OwntracksLocationRequest reque return ResponseEntity.ok(new ArrayList()); } - this.locationBatchingService.addLocationPoint(user, locationPoint); + this.locationBatchingService.addLocationPoint(user, user.getDevice().get(), locationPoint); logger.debug("Successfully received and queued GpsLogger location point for user {}", user.getUsername()); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java index c02a6ce0a..090bf862b 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java @@ -2,6 +2,7 @@ import com.dedicatedcode.reitti.dto.LocationPoint; import com.dedicatedcode.reitti.dto.OverlandLocationRequest; +import com.dedicatedcode.reitti.model.security.DeviceTokenUser; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.UserJdbcService; import com.dedicatedcode.reitti.service.LocationBatchingService; @@ -11,6 +12,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -39,11 +41,11 @@ public OverlandIngestionApiController(UserJdbcService userJdbcService, } @PostMapping("/overland") - public ResponseEntity receiveOverlandData(@RequestBody OverlandLocationRequest request) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername())); - + public ResponseEntity receiveOverlandData(@AuthenticationPrincipal DeviceTokenUser user, @RequestBody OverlandLocationRequest request) { + if (user.getDevice().isEmpty()) { + throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it."); + } + try { if (request.getLocations() == null || request.getLocations().isEmpty()) { logger.debug("Ignoring Overland request with no locations for user {}", user.getUsername()); @@ -67,7 +69,7 @@ public ResponseEntity receiveOverlandData(@RequestBody OverlandLocationReques // Add each location point to the batching service for (LocationPoint point : locationPoints) { - this.locationBatchingService.addLocationPoint(user, point); + this.locationBatchingService.addLocationPoint(user, user.getDevice().get(), point); } logger.debug("Successfully received and queued {} Overland location points for user {}", locationPoints.size(), user.getUsername()); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java index dcb942450..c753934d8 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java @@ -5,6 +5,7 @@ import com.dedicatedcode.reitti.dto.ReittiRemoteInfo; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; import com.dedicatedcode.reitti.model.integration.ReittiIntegration; +import com.dedicatedcode.reitti.model.security.DeviceTokenUser; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSharing; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; @@ -19,10 +20,7 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -58,10 +56,10 @@ public OwntracksIngestionApiController(UserJdbcService userJdbcService, } @PostMapping("/owntracks") - public ResponseEntity receiveOwntracksData(@RequestBody OwntracksLocationRequest request) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername())); + public ResponseEntity receiveOwntracksData(@AuthenticationPrincipal DeviceTokenUser user, @RequestBody OwntracksLocationRequest request) { + if (user.getDevice().isEmpty()) { + throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it."); + } try { if (!request.isLocationUpdate()) { @@ -79,7 +77,7 @@ public ResponseEntity receiveOwntracksData(@RequestBody OwntracksLocationRequ return ResponseEntity.ok(new ArrayList()); } - this.locationBatchingService.addLocationPoint(user, locationPoint); + this.locationBatchingService.addLocationPoint(user, user.getDevice().get(), locationPoint); logger.debug("Successfully received and queued Owntracks location point for user {}", user.getUsername()); @@ -136,7 +134,7 @@ private List buildFriendsData(User user) { ReittiRemoteInfo info = reittiIntegrationService.getInfo(integration); String tid = generateTid(info.userInfo().username()); - OwntracksFriendResponse owntracksFriendResponse = reittiIntegrationService.getAvatar(user, integration.getId()) + OwntracksFriendResponse owntracksFriendResponse = reittiIntegrationService.getAvatar(integration.getId()) .map(avatarData -> new OwntracksFriendResponse(tid, info.userInfo().displayName(), avatarData.imageData(), avatarData.mimeType())) .orElse(new OwntracksFriendResponse(tid, info.userInfo().displayName(), null, null)); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java index a7b7c9405..165282256 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java @@ -1,11 +1,11 @@ package com.dedicatedcode.reitti.controller.api.v2; import com.dedicatedcode.reitti.dto.MapMetadata; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.TokenUser; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; -import com.dedicatedcode.reitti.repository.UserJdbcService; -import com.dedicatedcode.reitti.repository.UserSharingJdbcService; +import com.dedicatedcode.reitti.repository.*; +import com.dedicatedcode.reitti.service.GeoJsonExportService; import com.dedicatedcode.reitti.service.StreamingRawLocationPointJdbcService; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -13,7 +13,12 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -27,17 +32,26 @@ public class LocationApiController { private final UserJdbcService userJdbcService; + private final DeviceJdbcService deviceJdbcService; private final UserSharingJdbcService userSharingJdbcService; private final RawLocationPointJdbcService jdbcService; + private final SourceLocationPointJdbcService sourceLocationPointJdbcService; + private final GeoJsonExportService geoJsonExportService; private final StreamingRawLocationPointJdbcService streamingRawLocationPointJdbcService; public LocationApiController(UserJdbcService userJdbcService, + DeviceJdbcService deviceJdbcService, UserSharingJdbcService userSharingJdbcService, RawLocationPointJdbcService jdbcService, + SourceLocationPointJdbcService sourceLocationPointJdbcService, + GeoJsonExportService geoJsonExportService, StreamingRawLocationPointJdbcService streamingRawLocationPointJdbcService) { this.userJdbcService = userJdbcService; + this.deviceJdbcService = deviceJdbcService; this.userSharingJdbcService = userSharingJdbcService; this.jdbcService = jdbcService; + this.sourceLocationPointJdbcService = sourceLocationPointJdbcService; + this.geoJsonExportService = geoJsonExportService; this.streamingRawLocationPointJdbcService = streamingRawLocationPointJdbcService; } @@ -53,6 +67,21 @@ public MapMetadata get(@AuthenticationPrincipal User user, return this.jdbcService.getMetadata(userToFetchDataFrom, startInstant, endInstant); } + @GetMapping("/metadata/{userId}/device/{deviceId}") + public MapMetadata getForDevice(@AuthenticationPrincipal User user, + @PathVariable Long userId, + @PathVariable Long deviceId, + @RequestParam String start, + @RequestParam String end, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) throws IllegalAccessException { + User userToFetchDataFrom = loadUserToFetchDataFrom(user, userId); + Device device = deviceJdbcService.find(userToFetchDataFrom, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found")); + + Instant startInstant = parseInstant(start, timezone, false); + Instant endInstant = parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS); + return this.sourceLocationPointJdbcService.getMetadata(userToFetchDataFrom, device, startInstant, endInstant); + } + @GetMapping(value = "/stream/{userId}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) public ResponseEntity stream( @AuthenticationPrincipal User user, @@ -65,7 +94,7 @@ public ResponseEntity stream( CompletableFuture.runAsync(() -> { try { - streamingRawLocationPointJdbcService.streamPoints(userToFetchDataFrom.getId(), parseInstant(start, timezone, false), parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS), emitter); + streamingRawLocationPointJdbcService.streamPoints(userToFetchDataFrom, parseInstant(start, timezone, false), parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS), emitter); } catch (Exception e) { if (e.getCause() instanceof java.io.IOException) { try { emitter.complete(); } catch (Exception ignored) {} @@ -80,6 +109,107 @@ public ResponseEntity stream( .header(HttpHeaders.CONTENT_ENCODING, "identity") .body(emitter); } + + @GetMapping(value = "/stream/{userId}/device/{deviceId}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity streamForDevice( + @AuthenticationPrincipal User user, + @PathVariable Long userId, + @PathVariable Long deviceId, + @RequestParam String start, + @RequestParam String end, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) throws IllegalAccessException { + User userToFetchDataFrom = loadUserToFetchDataFrom(user, userId); + Device device = deviceJdbcService.find(userToFetchDataFrom, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found")); + + ResponseBodyEmitter emitter = new ResponseBodyEmitter(0L); + + CompletableFuture.runAsync(() -> { + try { + streamingRawLocationPointJdbcService.streamPoints(userToFetchDataFrom, device, parseInstant(start, timezone, false), parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS), emitter); + } catch (Exception e) { + if (e.getCause() instanceof java.io.IOException) { + try { emitter.complete(); } catch (Exception ignored) {} + } else { + try { emitter.completeWithError(e); } catch (Exception ignored) {} + } + } + }); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE) + .header(HttpHeaders.CONTENT_ENCODING, "identity") + .body(emitter); + } + + @GetMapping(value = "/geojson/source", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity loadAsGeoJson(@AuthenticationPrincipal User user, + @RequestParam(name = "device", required = false) Long deviceId, + @RequestParam String start, + @RequestParam String end, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) { + try { + StreamingResponseBody stream = outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + geoJsonExportService.generateGeoJsonContentStreaming( + user, + parseInstant(start, timezone, false), + parseInstant(end, timezone, true), + deviceId, + writer); + } catch (Exception e) { + throw new RuntimeException("Error generating GeoJSON file", e); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(stream); + + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + writer.write("Error generating GeoJSON Stream: " + e.getMessage()); + } catch (IOException ioException) { + throw new RuntimeException(ioException); + } + }); + } + } + + @GetMapping(value = "/geojson", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity loadTimelineAsGeoJson(@AuthenticationPrincipal User user, + @RequestParam String start, + @RequestParam String end, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) { + try { + StreamingResponseBody stream = outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + geoJsonExportService.generateGeoJsonContentStreaming( + user, + parseInstant(start, timezone, false), + parseInstant(end, timezone, true), + writer); + } catch (Exception e) { + throw new RuntimeException("Error generating GeoJSON file", e); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(stream); + + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + writer.write("Error generating GeoJSON Stream: " + e.getMessage()); + } catch (IOException ioException) { + throw new RuntimeException(ioException); + } + }); + } + } private User loadUserToFetchDataFrom(User user, Long userId) throws IllegalAccessException { if (user.getId().equals(userId)) { return user; @@ -97,22 +227,18 @@ private User loadUserToFetchDataFrom(User user, Long userId) throws IllegalAcces .orElseThrow(() -> new RuntimeException("User not found")); } private Instant parseInstant(String input, ZoneId timezone, boolean end) { - LocalDateTime dateTime = null; try { - dateTime = LocalDateTime.parse(input); - } catch (Exception ignored) {} + return LocalDateTime.parse(input).atZone(timezone).toInstant(); + } catch (Exception ignored) { + } - if (dateTime == null) { - try { - dateTime = LocalDateTime.parse(input + (end ? "T23:59:59" : "T00:00:00")); - return dateTime.atZone(timezone).toInstant(); - } catch (Exception ignored) {} + try { + return LocalDateTime.parse(input + (end ? "T23:59:59" : "T00:00:00")).atZone(timezone).toInstant(); + } catch (Exception ignored) { } - if (dateTime == null) { - try { - dateTime = ZonedDateTime.parse(input).toLocalDateTime(); - return dateTime.atZone(timezone).toInstant(); - } catch (Exception ignored) {} + try { + return ZonedDateTime.parse(input).toInstant(); + } catch (Exception ignored) { } throw new IllegalArgumentException("Invalid date format"); } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/MetadataApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/MetadataApiController.java new file mode 100644 index 000000000..b6798432c --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/MetadataApiController.java @@ -0,0 +1,79 @@ +package com.dedicatedcode.reitti.controller.api.v2; + +import com.dedicatedcode.reitti.model.metadata.MemoryMetadata; +import com.dedicatedcode.reitti.model.metadata.Mood; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.TripJdbcService; +import com.dedicatedcode.reitti.service.MetadataOverrideService; +import com.dedicatedcode.reitti.service.processing.TimeRange; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v2/metadata") +public class MetadataApiController { + private final TripJdbcService tripJdbcService; + private final ProcessedVisitJdbcService processedVisitJdbcService; + private final MetadataOverrideService metadataOverrideService; + + public MetadataApiController(TripJdbcService tripJdbcService, + ProcessedVisitJdbcService processedVisitJdbcService, + MetadataOverrideService metadataOverrideService) { + this.tripJdbcService = tripJdbcService; + this.processedVisitJdbcService = processedVisitJdbcService; + this.metadataOverrideService = metadataOverrideService; + } + + @GetMapping("/{type}/{id}") + public MemoryMetadata getMetadata(@AuthenticationPrincipal User user, @PathVariable String type, @PathVariable Long id) { + TimeRange timeRange = findTimeRange(type, id); + return this.metadataOverrideService.findOverlappingMetadata(user, timeRange.start(), timeRange.end()).orElse(null); + } + + @PostMapping("/{type}/{id}") + @Transactional + public MemoryMetadata postMetadata(@AuthenticationPrincipal User user, + @RequestParam(required = false) String mood, + @RequestParam(required = false) String reason, + @RequestParam(required = false) String notes, + @RequestParam(required = false) List tags, + @PathVariable String type, + @PathVariable Long id) { + + return switch (type) { + case "trip" -> this.tripJdbcService.findById(id).map(t -> { + MemoryMetadata memoryMetadata = new MemoryMetadata(t.getStartTime(), t.getEndTime()); + memoryMetadata.setMood(mood != null ? Mood.valueOf(mood) : null); + memoryMetadata.setReason(reason); + memoryMetadata.setDescription(notes); + memoryMetadata.setTags(tags); + this.metadataOverrideService.saveTripMetadata(user, t, memoryMetadata); + return memoryMetadata; + }).orElseThrow(() -> new IllegalArgumentException("Trip not found")); + case "visit" -> this.processedVisitJdbcService.findById(id).map(p -> { + MemoryMetadata memoryMetadata = new MemoryMetadata(p.getStartTime(), p.getEndTime()); + memoryMetadata.setMood(mood != null ? Mood.valueOf(mood) : null); + memoryMetadata.setReason(reason); + memoryMetadata.setDescription(notes); + memoryMetadata.setTags(tags); + this.metadataOverrideService.saveVisitMetadata(user, p, memoryMetadata); + return memoryMetadata; + }).orElseThrow(() -> new IllegalArgumentException("Visit not found")); + default -> throw new IllegalStateException("Unexpected value: " + type); + }; + } + + private TimeRange findTimeRange(String type, Long id) { + return switch (type) { + case ("trip") -> + this.tripJdbcService.findById(id).map(t -> TimeRange.of(t.getStartTime(), t.getEndTime())).orElseThrow(() -> new IllegalArgumentException("Trip not found")); + case ("visit") -> + this.processedVisitJdbcService.findById(id).map(p -> TimeRange.of(p.getStartTime(), p.getEndTime())).orElseThrow(() -> new IllegalArgumentException("Visit not found")); + default -> throw new IllegalStateException("Unexpected value: " + type); + }; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/WorkbenchApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/WorkbenchApiController.java new file mode 100644 index 000000000..ba2cb2b26 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/WorkbenchApiController.java @@ -0,0 +1,35 @@ +package com.dedicatedcode.reitti.controller.api.v2; + +import com.dedicatedcode.reitti.dto.workbench.WorkbenchCommitRequest; +import com.dedicatedcode.reitti.dto.workbench.WorkbenchCommitResponse; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.I18nService; +import com.dedicatedcode.reitti.service.workbench.WorkbenchService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v2/workbench") +public class WorkbenchApiController { + private final WorkbenchService workbenchService; + private final I18nService i18n; + public WorkbenchApiController(WorkbenchService workbenchService, I18nService i18n) { + this.workbenchService = workbenchService; + this.i18n = i18n; + } + + @PostMapping("/commit") + public ResponseEntity commit(@AuthenticationPrincipal User user, + @RequestBody WorkbenchCommitRequest request) { + try { + workbenchService.applyCommit(user, request); + return ResponseEntity.ok(new WorkbenchCommitResponse(true, i18n.translate("workbench.commit.success"))); + } catch (Exception e) { + return ResponseEntity.ok(new WorkbenchCommitResponse(false, i18n.translate("workbench.commit.failure", e.getMessage()))); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java index 07bcd839d..8ebdabdc2 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java @@ -1,10 +1,12 @@ package com.dedicatedcode.reitti.controller.settings; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.ApiToken; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ApiTokenJdbcService; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; import com.dedicatedcode.reitti.service.ApiTokenService; -import com.dedicatedcode.reitti.service.TimeUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; @@ -15,22 +17,29 @@ import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.List; +import java.util.Optional; import static com.dedicatedcode.reitti.service.TimeUtil.adjustInstant; @Controller @RequestMapping("/settings/api-tokens") public class ApiTokenSettingsController { + private static final int MAX_TOKEN_USAGES = 10; + private final ApiTokenService apiTokenService; + private final ApiTokenJdbcService apiTokenJdbcService; + private final DeviceJdbcService deviceJdbcService; private final MessageSource messageSource; private final boolean dataManagementEnabled; - public ApiTokenSettingsController(ApiTokenService apiTokenService, + public ApiTokenSettingsController(ApiTokenService apiTokenService, ApiTokenJdbcService apiTokenJdbcService, + DeviceJdbcService deviceJdbcService, MessageSource messageSource, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { this.apiTokenService = apiTokenService; + this.apiTokenJdbcService = apiTokenJdbcService; + this.deviceJdbcService = deviceJdbcService; this.messageSource = messageSource; this.dataManagementEnabled = dataManagementEnabled; } @@ -39,11 +48,10 @@ public ApiTokenSettingsController(ApiTokenService apiTokenService, public String getPage(@AuthenticationPrincipal User user, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, Model model) { + addCommonAttributes(timezone, user, model); model.addAttribute("activeSection", "api-tokens"); model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); model.addAttribute("dataManagementEnabled", dataManagementEnabled); - model.addAttribute("tokens", apiTokenService.getTokensForUser(user).stream() - .map(t -> new ApiTokeDTO(t.getId(), t.getToken(), t.getName(), adjustInstant(t.getCreatedAt(), timezone), adjustInstant(t.getLastUsedAt(),timezone))).toList()); return "settings/api-tokens"; } @@ -53,13 +61,17 @@ public String getTokenUsages(@AuthenticationPrincipal User user, Model model) { model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, 10) .stream() - .map(t -> new ApiTokenUsageDTO(t.token(), t.name(), adjustInstant(t.at(), timezone), t.endpoint(), t.ip())) + .map(t -> new ApiTokenUsageDTO(t.token(), t.name(), t.device(), adjustInstant(t.at(), timezone), t.endpoint(), t.ip())) .toList()); model.addAttribute("maxUsagesToShow", 10); return "settings/api-tokens :: api-token-usages"; } + @PostMapping - public String createToken(@AuthenticationPrincipal User user, @RequestParam String name, Model model) { + public String createToken(@AuthenticationPrincipal User user, + @RequestParam String name, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { try { apiTokenService.createToken(user, name); model.addAttribute("successMessage", getMessage("message.success.token.created")); @@ -67,18 +79,95 @@ public String createToken(@AuthenticationPrincipal User user, @RequestParam Stri model.addAttribute("errorMessage", getMessage("message.error.token.creation", e.getMessage())); } - // Get updated token list and add to model - List tokens = apiTokenService.getTokensForUser(user); - model.addAttribute("tokens", tokens); - model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, 10)); - model.addAttribute("maxUsagesToShow", 10); + addCommonAttributes(timezone, user, model); + return "settings/api-tokens :: api-tokens-content"; + } - // Return the api-tokens-content fragment + @PostMapping("/{id}/detach/{deviceId}") + public String detachFromDevice(@AuthenticationPrincipal User user, + @PathVariable Long id, + @PathVariable Long deviceId, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + + Optional tokenById = this.apiTokenService.getTokenById(user, id); + + if (tokenById.isEmpty()) { + throw new IllegalArgumentException("Token not found"); + } + + Optional deviceById = this.deviceJdbcService.find(user, deviceId); + if (deviceById.isEmpty()) { + throw new IllegalArgumentException("Device not found"); + } + + try { + apiTokenJdbcService.save(tokenById.get().withDevice(null)); + model.addAttribute("successMessage", getMessage("message.success.token.detached", deviceById.get().name())); + } catch (Exception e) { + model.addAttribute("errorMessage", getMessage("message.error.generic", e.getMessage())); + } + + addCommonAttributes(timezone, user, model); + return "settings/api-tokens :: api-tokens-content"; + } + + @PostMapping("/link/{id}") + public String linkToDevice(@AuthenticationPrincipal User user, + @PathVariable Long id, + @RequestParam Long deviceId, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + + Optional tokenById = this.apiTokenService.getTokenById(user, id); + + if (tokenById.isEmpty()) { + throw new IllegalArgumentException("Token not found"); + } + + Optional deviceById = this.deviceJdbcService.find(user, deviceId); + if (deviceById.isEmpty()) { + throw new IllegalArgumentException("Device not found"); + } + + try { + apiTokenJdbcService.save(tokenById.get().withDevice(deviceById.get())); + model.addAttribute("successMessage", getMessage("message.success.token.attach", deviceById.get().name())); + } catch (Exception e) { + model.addAttribute("errorMessage", getMessage("message.error.generic", e.getMessage())); + } + addCommonAttributes(timezone, user, model); + return "settings/api-tokens :: api-tokens-content"; + } + + @GetMapping("/{tokenId}/link-form") + public String linkForm(@AuthenticationPrincipal User user, + @PathVariable Long tokenId, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + Optional tokenById = this.apiTokenService.getTokenById(user, tokenId); + if (tokenById.isEmpty()) { + throw new IllegalArgumentException("Token not found"); + } else { + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); + model.addAttribute("token", toDto(timezone, tokenById.get())); + return "settings/fragments/api-tokens :: link-form"; + } + } + + @GetMapping("/tokens") + public String tokensContent(@AuthenticationPrincipal User user, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + addCommonAttributes(timezone, user, model); return "settings/api-tokens :: api-tokens-content"; } @PostMapping("/{tokenId}/delete") - public String deleteToken(@PathVariable Long tokenId, @AuthenticationPrincipal User user, Model model) { + public String deleteToken(@PathVariable Long tokenId, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + @AuthenticationPrincipal User user, + Model model) { try { apiTokenService.deleteToken(tokenId); @@ -87,22 +176,34 @@ public String deleteToken(@PathVariable Long tokenId, @AuthenticationPrincipal U model.addAttribute("errorMessage", getMessage("message.error.token.deletion", e.getMessage())); } - // Get updated token list and add to model - List tokens = apiTokenService.getTokensForUser(user); - model.addAttribute("tokens", tokens); - model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, 10)); - model.addAttribute("maxUsagesToShow", 10); - - // Return the api-tokens-content fragment + addCommonAttributes(timezone, user, model); return "settings/api-tokens :: api-tokens-content"; } - public record ApiTokeDTO(Long id, String token, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {} + private void addCommonAttributes(ZoneId timezone, User user, Model model) { + model.addAttribute("tokens", apiTokenService.getTokensForUser(user).stream() + .map(t -> toDto(timezone, t)).toList()); + model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, MAX_TOKEN_USAGES)); + model.addAttribute("maxUsagesToShow", MAX_TOKEN_USAGES); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); + } + + public record ApiTokenDto(Long id, Long deviceId, String deviceName, String token, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {} - public record ApiTokenUsageDTO(String token, String name, LocalDateTime at, String endpoint, String ip) { + public record ApiTokenUsageDTO(String token, String name, String device, LocalDateTime at, String endpoint, String ip) { } private String getMessage(String key, Object... args) { return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); } + + private static ApiTokenDto toDto(ZoneId timezone, ApiToken t) { + return new ApiTokenDto(t.getId(), + t.getDevice() != null ? t.getDevice().id() : null, + t.getDevice() != null ? t.getDevice().name() : null, + t.getToken(), + t.getName(), + adjustInstant(t.getCreatedAt(), timezone), + adjustInstant(t.getLastUsedAt(), timezone)); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/DeviceSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/DeviceSettingsController.java new file mode 100644 index 000000000..0b3db6ab3 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/DeviceSettingsController.java @@ -0,0 +1,377 @@ +package com.dedicatedcode.reitti.controller.settings; + +import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.ApiToken; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ApiTokenJdbcService; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import com.dedicatedcode.reitti.service.AvatarService; +import com.dedicatedcode.reitti.service.ContextPathHolder; +import com.dedicatedcode.reitti.service.I18nService; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.ui.Model; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +@Controller +@RequestMapping("/settings/devices") +public class DeviceSettingsController { + // Avatar constraints + private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB + private static final String[] ALLOWED_CONTENT_TYPES = { + "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp" + }; + private static final List DEFAULT_AVATARS = Arrays.asList( + "gps.jpg", "smartwatch.jpg", "phone.jpg" + ); + + private final DeviceJdbcService deviceJdbcService; + private final ApiTokenJdbcService apiTokenJdbcService; + private final AvatarService avatarService; + private final I18nService i18n; + private final ContextPathHolder contextPathHolder; + + public DeviceSettingsController(DeviceJdbcService deviceJdbcService, + ApiTokenJdbcService apiTokenJdbcService, + AvatarService avatarService, + I18nService i18n, ContextPathHolder contextPathHolder) { + this.deviceJdbcService = deviceJdbcService; + this.apiTokenJdbcService = apiTokenJdbcService; + this.avatarService = avatarService; + this.i18n = i18n; + this.contextPathHolder = contextPathHolder; + } + + @GetMapping + public String getPage(@AuthenticationPrincipal User user, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + model.addAttribute("activeSection", "devices"); + model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); + model.addAttribute("defaultColors", getDefaultColors()); + model.addAttribute("defaultAvatars", DEFAULT_AVATARS); + model.addAttribute("devices", deviceJdbcService.getAll(user).stream() + .map(d -> new DeviceDTO(d.id(), d.name(), d.color(), + createAvatarUrl(user, d), avatarService.generateInitials(d.name()), d.enabled(), d.showOnMap(), d.showAvatarOnMap(), d.defaultDevice(), + adjustInstant(d.createdAt(), timezone), adjustInstant(d.updatedAt(), timezone))) + .toList()); + return "settings/devices"; + } + + @GetMapping("/edit/{deviceId}") + public String editDevice(@PathVariable Long deviceId, + @AuthenticationPrincipal User user, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + List devices = deviceJdbcService.getAll(user); + Device device = devices.stream() + .filter(d -> d.id().equals(deviceId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Device not found")); + + model.addAttribute("defaultColors", getDefaultColors()); + model.addAttribute("defaultAvatars", DEFAULT_AVATARS); + model.addAttribute("selectedColor", device.color()); + model.addAttribute("device", + new DeviceDTO(device.id(), device.name(), device.color(), + createAvatarUrl(user, device), avatarService.generateInitials(device.name()), + device.enabled(), device.showOnMap(), device.showAvatarOnMap(), device.defaultDevice(), + adjustInstant(device.createdAt(), timezone), adjustInstant(device.updatedAt(), timezone))); + boolean hasAvatar = this.avatarService.getInfo(user.getId(), device.id()).isPresent(); + model.addAttribute("hasAvatar", hasAvatar); + + return "settings/devices :: device-edit-form"; + } + + @PostMapping + public String createDevice(@AuthenticationPrincipal User user, + @RequestParam String name, + @RequestParam String color, + @RequestParam(required = false, defaultValue = "false") boolean enabled, + @RequestParam(required = false, defaultValue = "false") boolean showOnMap, + @RequestParam(required = false, defaultValue = "false") boolean showAvatarOnMap, + @RequestParam(required = false) String defaultAvatar, + @RequestParam(required = false) MultipartFile avatar, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + try { + Instant now = Instant.now(); + Device device = new Device( + null, + name, + enabled, + showOnMap, + showAvatarOnMap, + color, + false, + now, + now, + 1L + ); + Device saved = deviceJdbcService.save(device, user); + ApiToken apiToken = new ApiToken(user, saved.name(), saved); + this.apiTokenJdbcService.save(apiToken); + + // Handle avatar operations + if (avatar != null && !avatar.isEmpty()) { + handleAvatarUpload(avatar, user.getId(), saved.id(), model); + } else if (StringUtils.hasText(defaultAvatar)) { + handleDefaultAvatarSelection(defaultAvatar, user.getId(), saved.id(), model); + } + + model.addAttribute("successMessage", i18n.translate("message.success.device.created")); + } catch (Exception e) { + model.addAttribute("errorMessage", i18n.translate("message.error.device.creation", e.getMessage())); + } + + addDevicesToModel(user, timezone, model); + + return "settings/devices :: devices-content"; + } + + @PostMapping("/{deviceId}") + public String updateDevice(@PathVariable Long deviceId, + @AuthenticationPrincipal User user, + @RequestParam String name, + @RequestParam String color, + @RequestParam(required = false, defaultValue = "false") boolean enabled, + @RequestParam(required = false, defaultValue = "false") boolean showOnMap, + @RequestParam(required = false, defaultValue = "false") boolean showAvatarOnMap, + @RequestParam(required = false) String defaultAvatar, + @RequestParam(required = false) String removeAvatar, + @RequestParam(required = false) MultipartFile avatar, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + try { + List devices = deviceJdbcService.getAll(user); + Device existingDevice = devices.stream() + .filter(d -> d.id().equals(deviceId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Device not found")); + + Device updatedDevice = new Device( + deviceId, + name, + enabled, + showOnMap, + showAvatarOnMap, + color, + existingDevice.defaultDevice(), + existingDevice.createdAt(), + Instant.now(), + existingDevice.version() + 1 + ); + deviceJdbcService.update(updatedDevice, user); + + + // Handle avatar operations + if ("true".equals(removeAvatar)) { + avatarService.deleteAvatar(user.getId(), deviceId); + } else if (avatar != null && !avatar.isEmpty()) { + handleAvatarUpload(avatar, user.getId(), deviceId, model); + } else if (StringUtils.hasText(defaultAvatar)) { + handleDefaultAvatarSelection(defaultAvatar, user.getId(), deviceId, model); + } + + model.addAttribute("successMessage", i18n.translate("message.success.device.updated")); + } catch (Exception e) { + model.addAttribute("errorMessage", i18n.translate("message.error.device.update", e.getMessage())); + } + + addDevicesToModel(user, timezone, model); + + return "settings/devices :: devices-content"; + } + + @PostMapping("/{deviceId}/toggle") + public String toggleDevice(@PathVariable Long deviceId, + @AuthenticationPrincipal User user, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + try { + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found")); + Device updatedDevice = new Device( + device.id(), + device.name(), + !device.enabled(), + device.showOnMap(), + device.showAvatarOnMap(), + device.color(), + device.defaultDevice(), + device.createdAt(), + Instant.now(), + device.version() + 1 + ); + this.deviceJdbcService.update(updatedDevice, user); + model.addAttribute("successMessage", i18n.translate("message.success.device.toggled")); + } catch (Exception e) { + model.addAttribute("errorMessage", i18n.translate("message.error.device.toggle", e.getMessage())); + } + + addDevicesToModel(user, timezone, model); + + return "settings/devices :: devices-content"; + } + + @PostMapping("/{deviceId}/set-default") + @Transactional + public String setToDefault(@PathVariable Long deviceId, + @AuthenticationPrincipal User user, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + try { + + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found")); + Device oldDefaultDevice = this.deviceJdbcService.getDefaultDevice(user); + + oldDefaultDevice = oldDefaultDevice.withDefaultDevice(false); + device = device.withDefaultDevice(true); + this.deviceJdbcService.update(oldDefaultDevice, user); + this.deviceJdbcService.update(device, user); + model.addAttribute("successMessage", i18n.translate("message.success.device.default-device", device.name())); + } catch (Exception e) { + model.addAttribute("errorMessage", i18n.translate("message.error.device.toggle", e.getMessage())); + } + + addDevicesToModel(user, timezone, model); + + return "settings/devices :: devices-content"; + } + + @PostMapping("/{deviceId}/delete") + public String deleteDevice(@PathVariable Long deviceId, @AuthenticationPrincipal User user, + @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, + Model model) { + try { + Device deviceToDelete = this.deviceJdbcService.find(user, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found")); + + if (deviceToDelete.defaultDevice()) { + model.addAttribute("errorMessage", i18n.translate("message.error.device.deletion.default")); + } else { + deviceJdbcService.delete(deviceToDelete, user); + model.addAttribute("successMessage", i18n.translate("message.success.device.deleted")); + } + } catch (Exception e) { + model.addAttribute("errorMessage", i18n.translate("message.error.device.deletion", e.getMessage())); + } + + addDevicesToModel(user, timezone, model); + + // Return the devices-content fragment + return "settings/devices :: devices-content"; + } + + private void handleAvatarUpload(MultipartFile avatar, Long userId, Long deviceId, Model model) { + if (avatar != null && !avatar.isEmpty()) { + try { + // Validate file size + if (avatar.getSize() > MAX_AVATAR_SIZE) { + model.addAttribute("avatarError", i18n.translate("users.avatar.error.to-large")); + return; + } + + // Validate content type + String contentType = avatar.getContentType(); + if (contentType == null || !isAllowedContentType(contentType)) { + model.addAttribute("avatarError", i18n.translate("users.avatar.error.invalid-file-type")); + return; + } + + byte[] imageData = avatar.getBytes(); + this.avatarService.updateAvatar(userId, deviceId, contentType, imageData); + + } catch (IOException e) { + model.addAttribute("avatarError", i18n.translate("devices.avatar.error.generic", e.getMessage())); + } + } + } + + private void handleDefaultAvatarSelection(String defaultAvatar, Long userId, Long deviceId, Model model) { + try { + if (!DEFAULT_AVATARS.contains(defaultAvatar)) { + model.addAttribute("avatarError", "Invalid default avatar selection."); + return; + } + ClassPathResource resource = new ClassPathResource("static/img/avatars/default/" + defaultAvatar); + if (!resource.exists()) { + model.addAttribute("avatarError", "Default avatar file not found."); + return; + } + + byte[] imageData = resource.getInputStream().readAllBytes(); + String mimeType = "image/jpeg"; + + this.avatarService.updateAvatar(userId, deviceId, mimeType, imageData); + + } catch (IOException e) { + model.addAttribute("avatarError", "Error processing default avatar: " + e.getMessage()); + } + } + + private String createAvatarUrl(User user, Device device) { + if (this.avatarService.getInfo(user.getId(), device.id()).isPresent()) { + return String.format(contextPathHolder.getContextPath() + "/avatars/%d/%d?ts=%s", user.getId(), device.id(), Instant.now().toEpochMilli()); + } else { + return null; + } + } + + private boolean isAllowedContentType(String contentType) { + for (String allowed : ALLOWED_CONTENT_TYPES) { + if (allowed.equals(contentType)) { + return true; + } + } + return false; + } + + public record DeviceDTO(Long id, String name, String color, String avatarUrl, String avatarFallback, + boolean enabled, + boolean showOnMap, + boolean showAvatar, + boolean defaultDevice, + LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + private void addDevicesToModel(User user, ZoneId timezone, Model model) { + List devices = deviceJdbcService.getAll(user); + model.addAttribute("devices", devices.stream() + .map(d -> new DeviceDTO(d.id(), d.name(), d.color(), createAvatarUrl(user, d), avatarService.generateInitials(d.name()), d.enabled(), d.showOnMap(), d.showAvatarOnMap(), d.defaultDevice(), + adjustInstant(d.createdAt(), timezone), adjustInstant(d.updatedAt(), timezone))) + .toList()); + model.addAttribute("defaultColors", getDefaultColors()); + } + + private Map getDefaultColors() { + return Map.of( + "#f1ba63", "Orange", + "#ff6b6b", "Red", + "#4ecdc4", "Teal", + "#45b7d1", "Blue", + "#96ceb4", "Green", + "#ffeaa7", "Yellow", + "#dfe6e9", "Gray", + "#a29bfe", "Purple" + ); + } + + private LocalDateTime adjustInstant(Instant instant, ZoneId zoneId) { + if (instant == null || zoneId == null) { + return null; + } + return instant.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId).toLocalDateTime(); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java index bb10f3a16..d5fd2c0ce 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java @@ -1,9 +1,11 @@ package com.dedicatedcode.reitti.controller.settings; import com.dedicatedcode.reitti.model.Role; -import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService; import com.dedicatedcode.reitti.service.GpxExportService; import com.dedicatedcode.reitti.service.TimeUtil; import org.springframework.beans.factory.annotation.Value; @@ -35,14 +37,17 @@ @RequestMapping("/settings/export-data") public class ExportDataController { - private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final SourceLocationPointJdbcService sourceLocationPointJdbcService; + private final DeviceJdbcService deviceJdbcService; private final GpxExportService gpxExportService; private final boolean dataManagementEnabled; - public ExportDataController(RawLocationPointJdbcService rawLocationPointJdbcService, + public ExportDataController(SourceLocationPointJdbcService sourceLocationPointJdbcService, + DeviceJdbcService deviceJdbcService, GpxExportService gpxExportService, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { - this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.sourceLocationPointJdbcService = sourceLocationPointJdbcService; + this.deviceJdbcService = deviceJdbcService; this.gpxExportService = gpxExportService; this.dataManagementEnabled = dataManagementEnabled; } @@ -54,6 +59,7 @@ public void getExportDataContent(@AuthenticationPrincipal User user, Model model java.time.LocalDate today = java.time.LocalDate.now(); model.addAttribute("startDate", today); model.addAttribute("endDate", today); + model.addAttribute("devices", deviceJdbcService.getAll(user)); // Get raw location points for today by default model.addAttribute("rawLocationPoints", Collections.emptyList()); @@ -66,11 +72,12 @@ public void getExportDataContent(@AuthenticationPrincipal User user, Model model public String getExportDataContent(@AuthenticationPrincipal User user, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate, + @RequestParam(required = false) String deviceId, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, @RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "100") int size, Model model) { - + Device device = findDevice(user, deviceId); LocalDate start = StringUtils.hasText(startDate) ? LocalDate.parse(startDate) : LocalDate.now(); LocalDate end = StringUtils.hasText(endDate) ? LocalDate.parse(endDate) : LocalDate.now(); ZonedDateTime startDateTime = start.atStartOfDay(timezone); @@ -79,14 +86,14 @@ public String getExportDataContent(@AuthenticationPrincipal User user, model.addAttribute("endDate", end); // Get raw location points for the date range - List allPoints = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startDateTime.toInstant(), endDateTime.toInstant(), false, true, true, page, size); + List allPoints = sourceLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, device, startDateTime.toInstant(), endDateTime.toInstant(), true, true, page, size); - long totalElements = rawLocationPointJdbcService.countByUserAndTimestampBetweenOrderByTimestampAsc(user, startDateTime.toInstant(), endDateTime.toInstant(), false, true, true); + long totalElements = sourceLocationPointJdbcService.countByUserAndTimestampBetween(user, device, startDateTime.toInstant(), endDateTime.toInstant(), true, true); int totalPages = (int) Math.ceil((double) totalElements / size); List paginatedData = allPoints.stream() .map(p -> new DataLine(TimeUtil.adjustInstant(p.getTimestamp(), timezone), - p.getLatitude(), p.getLongitude(), p.getAccuracyMeters(), p.isProcessed())) + p.getLatitude(), p.getLongitude(), p.getAccuracyMeters())) .toList(); model.addAttribute("rawLocationPoints", paginatedData); @@ -99,14 +106,24 @@ public String getExportDataContent(@AuthenticationPrincipal User user, return "settings/export-data :: data-content"; } - + + private Device findDevice(User user, String deviceId) { + if (!StringUtils.hasText(deviceId) ) { + return null; + } else { + return deviceJdbcService.find(user, Long.valueOf(deviceId)).orElseThrow(IllegalArgumentException::new); + } + } + @GetMapping("/gpx") public ResponseEntity exportGpx(@AuthenticationPrincipal User user, + @RequestParam(required = false) String deviceId, @RequestParam String startDate, @RequestParam String endDate, @RequestParam boolean relevantDataOnly, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) { try { + Device device = findDevice(user, deviceId); LocalDate start = LocalDate.parse(startDate); LocalDate end = LocalDate.parse(endDate); ZonedDateTime startDateTime = start.atStartOfDay(timezone); @@ -118,7 +135,7 @@ public ResponseEntity exportGpx(@AuthenticationPrincipal StreamingResponseBody stream = outputStream -> { try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { - gpxExportService.generateGpxContentStreaming(user, startDateTime.toInstant(), endDateTime.toInstant(), writer, relevantDataOnly); + gpxExportService.generateGpxContentStreaming(user, device, startDateTime.toInstant(), endDateTime.toInstant(), writer, relevantDataOnly); } catch (Exception e) { throw new RuntimeException("Error generating GPX file", e); } @@ -141,6 +158,6 @@ public ResponseEntity exportGpx(@AuthenticationPrincipal } } - public record DataLine(LocalDateTime timestamp, double latitude, double longitude, double accuracyMeters, boolean processed) {} + public record DataLine(LocalDateTime timestamp, double latitude, double longitude, double accuracyMeters) {} } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java index e77ccbb75..981652cfd 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java @@ -1,67 +1,81 @@ package com.dedicatedcode.reitti.controller.settings; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import com.dedicatedcode.reitti.service.I18nService; import com.dedicatedcode.reitti.service.importer.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.apache.logging.log4j.util.Strings; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; +import java.util.Arrays; import java.util.Map; +import java.util.stream.Collectors; @Controller @RequestMapping("/settings/import") public class FileImportController { - private static final Logger logger = LoggerFactory.getLogger(FileImportController.class); - private final GpxImporter gpxImporter; private final GoogleRecordsImporter googleRecordsImporter; private final GoogleAndroidTimelineImporter googleAndroidTimelineImporter; private final GoogleIOSTimelineImporter googleTimelineIOSImporter; private final GeoJsonImporter geoJsonImporter; + private final DeviceJdbcService deviceJdbcService; + private final I18nService i18n; private final boolean dataManagementEnabled; + private final int maxFileSupported; + private final String maxFileSize; public FileImportController(GpxImporter gpxImporter, GoogleRecordsImporter googleRecordsImporter, GoogleAndroidTimelineImporter googleAndroidTimelineImporter, GoogleIOSTimelineImporter googleTimelineIOSImporter, - GeoJsonImporter geoJsonImporter, - @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { + GeoJsonImporter geoJsonImporter, DeviceJdbcService deviceJdbcService, + I18nService i18n, + @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + @Value("${server.tomcat.max-part-count}") int maxFileSupported, + @Value("${spring.servlet.multipart.max-file-size}") String maxFileSize) { this.gpxImporter = gpxImporter; this.googleRecordsImporter = googleRecordsImporter; this.googleAndroidTimelineImporter = googleAndroidTimelineImporter; this.googleTimelineIOSImporter = googleTimelineIOSImporter; this.geoJsonImporter = geoJsonImporter; + this.deviceJdbcService = deviceJdbcService; + this.i18n = i18n; this.dataManagementEnabled = dataManagementEnabled; + this.maxFileSupported = maxFileSupported; + this.maxFileSize = maxFileSize; } - @GetMapping public String getFileUploadPage(@AuthenticationPrincipal User user, Model model) { model.addAttribute("activeSection", "file-upload"); model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); model.addAttribute("dataManagementEnabled", dataManagementEnabled); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); return "settings/import-data"; } @PostMapping("/gpx") public String importGpx(@RequestParam("files") MultipartFile[] files, + @RequestParam("device") Long deviceId, Authentication authentication, Model model) { User user = (User) authentication.getPrincipal(); - + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); if (files.length == 0) { model.addAttribute("uploadErrorMessage", "No files selected"); return "settings/import-data :: file-upload-content"; @@ -72,29 +86,21 @@ public String importGpx(@RequestParam("files") MultipartFile[] files, StringBuilder errorMessages = new StringBuilder(); for (MultipartFile file : files) { - if (file.isEmpty() || file.getOriginalFilename() == null) { - errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. "); - continue; - } - - if (!file.getOriginalFilename().endsWith(".gpx")) { - errorMessages.append("File ").append(file.getOriginalFilename()).append(" is not a GPX file. "); - continue; - } - - try (InputStream inputStream = file.getInputStream()) { - Map result = this.gpxImporter.importGpx(inputStream, user); - - if ((Boolean) result.get("success")) { - totalProcessed += (Integer) result.get("pointsReceived"); - successCount++; - } else { + if (validateFile(file, errorMessages, "gpx")) { + try (InputStream inputStream = file.getInputStream()) { + Map result = this.gpxImporter.importGpx(inputStream, user, device, file.getOriginalFilename()); + + if ((Boolean) result.get("success")) { + totalProcessed += (Integer) result.get("pointsReceived"); + successCount++; + } else { + errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ") + .append(result.get("error")).append(". "); + } + } catch (IOException e) { errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ") - .append(result.get("error")).append(". "); + .append(e.getMessage()).append(". "); } - } catch (IOException e) { - errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ") - .append(e.getMessage()).append(". "); } } @@ -113,22 +119,21 @@ public String importGpx(@RequestParam("files") MultipartFile[] files, @PostMapping("/google-records") public String importGoogleRecords(@RequestParam("file") MultipartFile file, - Authentication authentication, - Model model) { + @RequestParam(name = "device") Long deviceId, + Authentication authentication, + Model model) { User user = (User) authentication.getPrincipal(); + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); - if (file.isEmpty() || file.getOriginalFilename() == null) { - model.addAttribute("uploadErrorMessage", "File is empty"); - return "settings/import-data :: file-upload-content"; - } - - if (!file.getOriginalFilename().endsWith(".json")) { - model.addAttribute("uploadErrorMessage", "Only JSON files are supported"); + StringBuilder errorMessages = new StringBuilder(); + if (!validateFile(file, errorMessages, "json")) { + model.addAttribute("uploadErrorMessage", errorMessages.toString()); return "settings/import-data :: file-upload-content"; } try (InputStream inputStream = file.getInputStream()) { - Map result = this.googleRecordsImporter.importGoogleRecords(inputStream, user); + Map result = this.googleRecordsImporter.importGoogleRecords(inputStream, user, device, file.getOriginalFilename()); if ((Boolean) result.get("success")) { model.addAttribute("uploadSuccessMessage", result.get("message")); @@ -145,22 +150,21 @@ public String importGoogleRecords(@RequestParam("file") MultipartFile file, @PostMapping("/google-timeline-android") public String importGoogleTimelineAndroid(@RequestParam("file") MultipartFile file, - Authentication authentication, - Model model) { + @RequestParam(name = "device") Long deviceId, + Authentication authentication, + Model model) { User user = (User) authentication.getPrincipal(); + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); - if (file.isEmpty() || file.getOriginalFilename() == null) { - model.addAttribute("uploadErrorMessage", "File is empty"); - return "settings/import-data :: file-upload-content"; - } - - if (!file.getOriginalFilename().endsWith(".json")) { - model.addAttribute("uploadErrorMessage", "Only JSON files are supported"); + StringBuilder errorMessages = new StringBuilder(); + if (!validateFile(file, errorMessages, "json")) { + model.addAttribute("uploadErrorMessage", errorMessages.toString()); return "settings/import-data :: file-upload-content"; } try (InputStream inputStream = file.getInputStream()) { - Map result = this.googleAndroidTimelineImporter.importTimeline(inputStream, user); + Map result = this.googleAndroidTimelineImporter.importTimeline(inputStream, user, device, file.getOriginalFilename()); if ((Boolean) result.get("success")) { model.addAttribute("uploadSuccessMessage", result.get("message")); @@ -177,22 +181,21 @@ public String importGoogleTimelineAndroid(@RequestParam("file") MultipartFile fi @PostMapping("/google-timeline-ios") public String importGoogleTimelineIOS(@RequestParam("file") MultipartFile file, - Authentication authentication, - Model model) { + @RequestParam("device") Long deviceId, + Authentication authentication, + Model model) { User user = (User) authentication.getPrincipal(); + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); - if (file.isEmpty() || file.getOriginalFilename() == null) { - model.addAttribute("uploadErrorMessage", "File is empty"); - return "settings/import-data :: file-upload-content"; - } - - if (!file.getOriginalFilename().endsWith(".json")) { - model.addAttribute("uploadErrorMessage", "Only JSON files are supported"); + StringBuilder errorMessages = new StringBuilder(); + if (!validateFile(file, errorMessages, "json")) { + model.addAttribute("uploadErrorMessage", errorMessages.toString()); return "settings/import-data :: file-upload-content"; } try (InputStream inputStream = file.getInputStream()) { - Map result = this.googleTimelineIOSImporter.importTimeline(inputStream, user); + Map result = this.googleTimelineIOSImporter.importTimeline(inputStream, user, device, file.getOriginalFilename()); if ((Boolean) result.get("success")) { model.addAttribute("uploadSuccessMessage", result.get("message")); @@ -209,9 +212,12 @@ public String importGoogleTimelineIOS(@RequestParam("file") MultipartFile file, @PostMapping("/geojson") public String importGeoJson(@RequestParam("files") MultipartFile[] files, + @RequestParam("device") Long deviceId, Authentication authentication, Model model) { User user = (User) authentication.getPrincipal(); + Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new); + model.addAttribute("devices", this.deviceJdbcService.getAll(user)); if (files.length == 0) { model.addAttribute("uploadErrorMessage", "No files selected"); @@ -223,19 +229,14 @@ public String importGeoJson(@RequestParam("files") MultipartFile[] files, StringBuilder errorMessages = new StringBuilder(); for (MultipartFile file : files) { - if (file.isEmpty()) { - errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. "); + if (!validateFile(file, errorMessages, "json", "geojson")) { + model.addAttribute("uploadErrorMessage", errorMessages.toString()); continue; } String filename = file.getOriginalFilename(); - if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) { - errorMessages.append("File ").append(filename).append(" is not a GeoJSON file. "); - continue; - } - try (InputStream inputStream = file.getInputStream()) { - Map result = this.geoJsonImporter.importGeoJson(inputStream, user); + Map result = this.geoJsonImporter.importGeoJson(inputStream, user, device, filename); if ((Boolean) result.get("success")) { totalProcessed += (Integer) result.get("pointsReceived"); @@ -251,9 +252,9 @@ public String importGeoJson(@RequestParam("files") MultipartFile[] files, } if (successCount > 0) { - String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points"; + String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points."; if (!errorMessages.isEmpty()) { - message += ". Errors: " + errorMessages; + message += " Errors: " + errorMessages; } model.addAttribute("uploadSuccessMessage", message); } else { @@ -262,4 +263,26 @@ public String importGeoJson(@RequestParam("files") MultipartFile[] files, return "settings/import-data :: file-upload-content"; } + + + private boolean validateFile(MultipartFile file, StringBuilder errorMessages, String... expectedExtension) { + if (file.isEmpty() || file.getOriginalFilename() == null) { + errorMessages.append(i18n.translate("upload.error.file.empty", file.getOriginalFilename())); + return false; + } + + if (Arrays.stream(expectedExtension).noneMatch(ext -> file.getOriginalFilename().toLowerCase().endsWith(ext.toLowerCase()))) { + errorMessages.append(i18n.translate("upload.error.file.wrong_extension", file.getOriginalFilename(), String.join(",", expectedExtension))); + return false; + } + + return true; + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.OK) + public String handleMaxUploadSizeExceededException(Model model) { + model.addAttribute("uploadErrorMessage", i18n.translate("upload.error.max_upload_size_exceeded", maxFileSupported, maxFileSize)); + return "settings/import-data :: file-upload-content"; + } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java index 7ecf644d5..7aea0ca6e 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java @@ -10,10 +10,11 @@ import com.dedicatedcode.reitti.repository.SignificantPlaceOverrideJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; import com.dedicatedcode.reitti.service.I18nService; -import com.dedicatedcode.reitti.service.geocoding.GeocodeResult; import com.dedicatedcode.reitti.service.geocoding.GeocodeService; import com.dedicatedcode.reitti.service.geocoding.GeocodeServiceManager; -import com.dedicatedcode.reitti.service.queue.RedisQueueService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.github.kagkarlsson.scheduler.task.Task; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -26,7 +27,6 @@ import java.util.*; import static com.dedicatedcode.reitti.model.geocoding.GeocoderType.GEO_APIFY; -import static com.dedicatedcode.reitti.service.MessageDispatcherService.PLACE_CREATED_QUEUE; @Controller @RequestMapping("/settings/geocode-services") @@ -37,7 +37,8 @@ public class GeoCodingSettingsController { private final SignificantPlaceJdbcService placeJdbcService; private final SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService; private final UserJdbcService userJdbcService; - private final RedisQueueService messageEnqueuer; + private final JobSchedulingService jobScheduler; + private final Task reverseGeocodingTask; private final I18nService i18n; private final boolean dataManagementEnabled; private final int maxErrors; @@ -49,7 +50,8 @@ public GeoCodingSettingsController(GeocodeServiceJdbcService geocodeServiceJdbcS SignificantPlaceJdbcService placeJdbcService, SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService, UserJdbcService userJdbcService, - RedisQueueService messageEnqueuer, + JobSchedulingService jobScheduler, + Task reverseGeocodingTask, I18nService i18n, @Value("${reitti.geocoding.photon.base-url:}") String photonBaseUrl, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, @@ -59,7 +61,8 @@ public GeoCodingSettingsController(GeocodeServiceJdbcService geocodeServiceJdbcS this.placeJdbcService = placeJdbcService; this.significantPlaceOverrideJdbcService = significantPlaceOverrideJdbcService; this.userJdbcService = userJdbcService; - this.messageEnqueuer = messageEnqueuer; + this.jobScheduler = jobScheduler; + this.reverseGeocodingTask = reverseGeocodingTask; this.i18n = i18n; this.dataManagementEnabled = dataManagementEnabled; this.maxErrors = maxErrors; @@ -120,7 +123,7 @@ public String testConfiguration(@RequestParam GeocoderType type, Map result = geocodeServiceManager.test(tmpService, testLat, testLng); model.addAttribute("testResult", result); - }catch (Exception e) { + } catch (Exception e) { model.addAttribute("testResult", Map.of("success", false, "message", e.getMessage())); } return "settings/fragments/geocoding :: test-result-display"; @@ -157,15 +160,15 @@ private GeocodeService verifySelection(GeocoderType type, String url, String api @PostMapping public String saveGeocodeService(@RequestParam(required = false) Long id, - @RequestParam String name, - @RequestParam(required = false) String url, - @RequestParam GeocoderType type, - @RequestParam(required = false) String apiKey, - @RequestParam(required = false) String language, - @RequestParam(required = false) Integer limit, - @RequestParam(required = false) Double radius, - @RequestParam int priority, - Model model) { + @RequestParam String name, + @RequestParam(required = false) String url, + @RequestParam GeocoderType type, + @RequestParam(required = false) String apiKey, + @RequestParam(required = false) String language, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Double radius, + @RequestParam int priority, + Model model) { try { Map params = new HashMap<>(); if (language != null && !language.isEmpty()) { @@ -258,7 +261,11 @@ public String runGeocoding(Authentication authentication, Model model) { place.getLongitudeCentroid(), UUID.randomUUID().toString() ); - messageEnqueuer.enqueue(PLACE_CREATED_QUEUE, event); + this.jobScheduler.enqueueTask(reverseGeocodingTask, event, + JobSchedulingService.Metadata.builder() + .user(currentUser) + .friendlyName("Manual reverse geocoding") + .jobType(JobType.REVERSE_GEOCODE).build()); } model.addAttribute("successMessage", i18n.translate("geocoding.run.success", nonGeocodedPlaces.size())); @@ -299,7 +306,11 @@ public String clearAndRerunGeocoding(Authentication authentication, Model model) place.getLongitudeCentroid(), UUID.randomUUID().toString() ); - messageEnqueuer.enqueue(PLACE_CREATED_QUEUE, event); + this.jobScheduler.enqueueTask(reverseGeocodingTask, event, + JobSchedulingService.Metadata.builder() + .user(currentUser) + .friendlyName("Manual reverse geocoding") + .jobType(JobType.REVERSE_GEOCODE).build()); } model.addAttribute("successMessage", i18n.translate("geocoding.clear.success", allPlaces.size())); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java index fdd563538..7351d69cd 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java @@ -2,11 +2,13 @@ import com.dedicatedcode.reitti.model.IntegrationTestResult; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; import com.dedicatedcode.reitti.model.integration.ImmichIntegration; import com.dedicatedcode.reitti.model.integration.OwnTracksRecorderIntegration; import com.dedicatedcode.reitti.model.security.ApiToken; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; import com.dedicatedcode.reitti.repository.MqttIntegrationJdbcService; import com.dedicatedcode.reitti.repository.OptimisticLockException; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; @@ -36,6 +38,7 @@ public class IntegrationsSettingsController { private final ContextPathHolder contextPathHolder; private final ApiTokenService apiTokenService; private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final DeviceJdbcService deviceJdbcService; private final ImmichIntegrationService immichIntegrationService; private final OwnTracksRecorderIntegrationService ownTracksRecorderIntegrationService; private final DynamicMqttProvider mqttProvider; @@ -45,7 +48,7 @@ public class IntegrationsSettingsController { public IntegrationsSettingsController(ContextPathHolder contextPathHolder, ApiTokenService apiTokenService, - RawLocationPointJdbcService rawLocationPointJdbcService, + RawLocationPointJdbcService rawLocationPointJdbcService, DeviceJdbcService deviceJdbcService, ImmichIntegrationService immichIntegrationService, OwnTracksRecorderIntegrationService ownTracksRecorderIntegrationService, DynamicMqttProvider mqttProvider, @@ -55,6 +58,7 @@ public IntegrationsSettingsController(ContextPathHolder contextPathHolder, this.contextPathHolder = contextPathHolder; this.apiTokenService = apiTokenService; this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.deviceJdbcService = deviceJdbcService; this.immichIntegrationService = immichIntegrationService; this.ownTracksRecorderIntegrationService = ownTracksRecorderIntegrationService; this.mqttProvider = mqttProvider; @@ -64,9 +68,7 @@ public IntegrationsSettingsController(ContextPathHolder contextPathHolder, } @GetMapping - public String getPage(@AuthenticationPrincipal User user, - HttpServletRequest request, - Model model) { + public String getPage(@AuthenticationPrincipal User user, Model model) { model.addAttribute("activeSection", "integrations"); model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); model.addAttribute("dataManagementEnabled", dataManagementEnabled); @@ -79,7 +81,7 @@ public String getIntegrationsContent(@AuthenticationPrincipal User currentUser, HttpServletRequest request, Model model, @RequestParam(required = false) String openSection) { - List tokens = apiTokenService.getTokensForUser(currentUser); + List tokens = getUsableTokens(currentUser); // Determine the token to use String tokenToUse = null; @@ -97,6 +99,7 @@ public String getIntegrationsContent(@AuthenticationPrincipal User currentUser, } model.addAttribute("selectedToken", tokenToUse); + model.addAttribute("devices", this.deviceJdbcService.getAll(currentUser).stream().toList()); model.addAttribute("tokens", tokens); Optional recorderIntegration = ownTracksRecorderIntegrationService.getIntegrationForUser(currentUser); @@ -130,6 +133,10 @@ public String getIntegrationsContent(@AuthenticationPrincipal User currentUser, return "settings/fragments/integrations :: integrations-content"; } + private List getUsableTokens(User currentUser) { + return apiTokenService.getTokensForUser(currentUser).stream().filter(t -> t.getDevice() != null).toList(); + } + private String calculateServerUrl(HttpServletRequest request) { // Build the server URL String scheme = request.getScheme(); @@ -213,10 +220,12 @@ public String saveOwnTracksRecorderIntegration(@RequestParam String baseUrl, @RequestParam(defaultValue = "false") boolean enabled, @RequestParam String authUsername, @RequestParam String authPassword, + @RequestParam Long reittiDeviceId, @AuthenticationPrincipal User currentUser, RedirectAttributes redirectAttributes) { try { - ownTracksRecorderIntegrationService.saveIntegration(currentUser, baseUrl, username, authUsername, authPassword, deviceId, enabled); + Device device = this.deviceJdbcService.find(currentUser, reittiDeviceId).orElseThrow(() -> new IllegalArgumentException("Device not found")); + ownTracksRecorderIntegrationService.saveIntegration(currentUser, device, baseUrl, username, authUsername, authPassword, deviceId, enabled); redirectAttributes.addFlashAttribute("successMessage", i18n.translate("integrations.owntracks.recorder.config.saved")); } catch (Exception e) { redirectAttributes.addFlashAttribute("errorMessage", i18n.translate("integrations.owntracks.recorder.config.error", e.getMessage())); @@ -253,7 +262,7 @@ public Map testOwnTracksRecorderConnection(@RequestParam String } @PostMapping("/owntracks-recorder-integration/load-historical") - public String loadOwnTracksRecorderHistoricalData(@AuthenticationPrincipal User currentUser, RedirectAttributes redirectAttributes, HttpServletRequest request) { + public String loadOwnTracksRecorderHistoricalData(@AuthenticationPrincipal User currentUser, RedirectAttributes redirectAttributes, HttpServletRequest request) { try { ownTracksRecorderIntegrationService.loadHistoricalData(currentUser); redirectAttributes.addFlashAttribute("successMessage", i18n.translate("integrations.owntracks.recorder.load.historical.success")); @@ -274,6 +283,7 @@ public String saveMqttIntegration( @RequestParam(name = "mqtt_username", required = false) String username, @RequestParam(name = "mqtt_password", required = false) String password, @RequestParam(name = "mqtt_payloadType") PayloadType payloadType, + @RequestParam(name = "mqtt_deviceId") Long deviceId, @RequestParam(name = "mqtt_enabled",defaultValue = "false") boolean enabled, RedirectAttributes redirectAttributes) { @@ -288,7 +298,11 @@ public String saveMqttIntegration( if (port < 1 || port > 65535) { redirectAttributes.addFlashAttribute("errorMessage", i18n.translate("integration.mqtt.error.port_range")); return "redirect:/settings/integrations/integrations-content?openSection=mqtt"; + } + if (this.deviceJdbcService.find(user, deviceId).isEmpty()) { + redirectAttributes.addFlashAttribute("errorMessage", i18n.translate("integration.mqtt.error.unknown_device")); + return "redirect:/settings/integrations/integrations-content?openSection=mqtt"; } MqttIntegration mqttIntegration = this.mqttIntegrationJdbcService.findByUser(user).orElse(MqttIntegration.empty()); @@ -302,6 +316,7 @@ public String saveMqttIntegration( .withUsername(username) .withPassword(password) .withPayloadType(payloadType) + .withDeviceId(deviceId) .withEnabled(enabled); mqttIntegrationJdbcService.save(user, updatedIntegration); if (wasEnabled && !updatedIntegration.isEnabled()) { @@ -374,6 +389,7 @@ public ResponseEntity> testMqttConnection( null, null, null, + null, null)); // Wait for the test result and handle it diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java index 4e3a9221f..45f47c883 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java @@ -1,26 +1,39 @@ package com.dedicatedcode.reitti.controller.settings; - import com.dedicatedcode.reitti.model.Role; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.QueueStatsService; +import com.dedicatedcode.reitti.repository.JobMetadataRepository; +import com.dedicatedcode.reitti.service.jobs.JobInfo; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobState; +import com.dedicatedcode.reitti.service.jobs.JobType; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; @Controller @RequestMapping("/settings") public class JobStatusController { - private final QueueStatsService queueStatsService; + private final boolean dataManagementEnabled; + private final JobMetadataRepository jobMetadataRepository; + private final JobSchedulingService jobSchedulingService; - public JobStatusController(QueueStatsService queueStatsService, - @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { - this.queueStatsService = queueStatsService; + public JobStatusController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + JobMetadataRepository jobMetadataRepository, + JobSchedulingService jobSchedulingService) { this.dataManagementEnabled = dataManagementEnabled; + this.jobMetadataRepository = jobMetadataRepository; + this.jobSchedulingService = jobSchedulingService; } @GetMapping("/job-status") @@ -28,13 +41,214 @@ public String getJobStatus(@AuthenticationPrincipal User user, Model model) { model.addAttribute("activeSection", "job-status"); model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); model.addAttribute("dataManagementEnabled", dataManagementEnabled); - model.addAttribute("queueStats", queueStatsService.getQueueStats()); return "settings/job-status"; } @GetMapping("/queue-stats-content") - public String getQueueStatsContent(Model model) { - model.addAttribute("queueStats", queueStatsService.getQueueStats()); + public String getQueueStatsContent(@RequestParam(defaultValue = "UTC") ZoneId timezone, Model model) { + // Fetch all non-SSE jobs in active states + List activeJobs = filterNonSSE( + jobMetadataRepository.findByStates( + List.of(JobState.PREPARING, JobState.AWAITING, JobState.RUNNING) + ) + ); + // Fetch all non-SSE jobs in terminal states (completed/failed) + List terminalJobs = filterNonSSE( + jobMetadataRepository.findByStates( + List.of(JobState.COMPLETED, JobState.FAILED) + ) + ); + + // Combine to get full picture of parents and children + List allJobs = new ArrayList<>(activeJobs); + allJobs.addAll(terminalJobs); + + // Separate parent and child jobs + Map> partitioned = allJobs.stream() + .collect(Collectors.partitioningBy(job -> job.getParentJobId() == null)); + List parentJobs = partitioned.get(true); + List childJobs = partitioned.get(false); + + // Group children by parent ID + Map> childrenByParent = childJobs.stream() + .collect(Collectors.groupingBy(JobMetadataRepository.JobMetadata::getParentJobId)); + + // Separate parent jobs into pending and fully complete (past) + List pendingParents = new ArrayList<>(); + List pastParents = new ArrayList<>(); + + for (JobMetadataRepository.JobMetadata parent : parentJobs) { + List children = childrenByParent.getOrDefault(parent.getId(), List.of()); + boolean hasActiveChildren = children.stream() + .anyMatch(child -> !isTerminal(child.getState())); + + if (!isTerminal(parent.getState()) || hasActiveChildren) { + pendingParents.add(parent); + } else { + pastParents.add(parent); + } + } + + // Build pending job info (with children details) + Map averageRuntimes = calculateAverageRuntimes(pastParents); + List pendingJobs = pendingParents.stream() + .map(parent -> buildPendingJobInfo(timezone, parent, childrenByParent, averageRuntimes)) + .collect(Collectors.toList()); + + // Build past job info (with duration) + List pastJobs = pastParents.stream() + .map(j -> mapToJobInfo(timezone, j)) + .sorted(Comparator.comparing(JobInfo::finishedAt).reversed()) + .limit(25) + .collect(Collectors.toList()); + + model.addAttribute("pendingJobs", pendingJobs); + model.addAttribute("pastJobs", pastJobs); return "settings/job-status :: queue-stats-content"; } -} + + @DeleteMapping("/job/{id}") + public String cancelJob(@PathVariable UUID id, + @RequestParam(defaultValue = "UTC") ZoneId timezone, + Model model) { + jobSchedulingService.cancel(id); + // Re-fetch and render the current status + return getQueueStatsContent(timezone, model); + } + + private boolean isTerminal(JobState state) { + return state == JobState.COMPLETED || state == JobState.FAILED; + } + + private List filterNonSSE(List jobs) { + return jobs.stream() + .filter(job -> job.getJobType() != JobType.SSE_EVENT) + .toList(); + } + + private JobInfo buildPendingJobInfo(ZoneId timezone, JobMetadataRepository.JobMetadata parent, + Map> childrenByParent, + Map averageRuntimes) { + List childrenMeta = childrenByParent.getOrDefault(parent.getId(), List.of()); + List children = childrenMeta.stream() + .map(j -> mapToJobInfo(timezone, j)) + .toList(); + long completedChildren = children.stream() + .filter(j -> isTerminal(j.state())) + .count(); + long totalChildren = children.size(); + + // Basic info from the parent itself + JobInfo base = mapToJobInfo(timezone, parent); + + // Average runtime estimate + AverageRuntime avgRuntime = averageRuntimes.get(parent.getJobType()); + Long estimatedDuration = avgRuntime != null ? avgRuntime.getEstimatedSeconds() : null; + + return new JobInfo( + base.id(), + base.name(), + base.description(), + base.state(), + base.enqueuedAt(), + base.scheduledAt(), + base.processingAt(), + base.finishedAt(), + base.canCancel(), + children, + completedChildren, + totalChildren, + estimatedDuration, + 0, // no progress for parent grouping + null + ); + } + + private Map calculateAverageRuntimes(List fullyCompleteParents) { + Map> durationsByType = new HashMap<>(); + for (JobMetadataRepository.JobMetadata job : fullyCompleteParents) { + if (job.getFinishedAt() != null && job.getEnqueuedAt() != null) { + long durationSeconds = Duration.between(job.getEnqueuedAt(), job.getFinishedAt()).getSeconds(); + if (durationSeconds > 0) { + durationsByType.computeIfAbsent(job.getJobType(), k -> new ArrayList<>()).add(durationSeconds); + } + } + } + Map result = new HashMap<>(); + for (Map.Entry> entry : durationsByType.entrySet()) { + List durations = entry.getValue(); + if (!durations.isEmpty()) { + long average = durations.stream().mapToLong(Long::longValue).sum() / durations.size(); + result.put(entry.getKey(), new AverageRuntime(average, durations.size())); + } + } + return result; + } + + private JobInfo mapToJobInfo(ZoneId timezone, JobMetadataRepository.JobMetadata metadata) { + JobState state = metadata.getState(); + String jobName = metadata.getFriendlyName(); + String jobDescription = String.format("User ID: %s, Type: %s", metadata.getUserId(), metadata.getJobType()); + boolean canCancel = state == JobState.AWAITING; + + return new JobInfo( + metadata.getId(), + jobName, + jobDescription, + state, + toLocalDateTime(metadata.getEnqueuedAt(), timezone), + toLocalDateTime(metadata.getScheduledAt(), timezone), + toLocalDateTime(metadata.getProcessingAt(), timezone), + toLocalDateTime(metadata.getFinishedAt(), timezone), + canCancel, + List.of(), + 0, + 0, + null, + progressPercent(metadata), + metadata.getProgressMessage() + ); + } + + private LocalDateTime toLocalDateTime(Instant instant, ZoneId timezone) { + if (instant == null || timezone == null) { + return null; + } else { + return instant.atZone(timezone).toLocalDateTime(); + } + } + private float progressPercent(JobMetadataRepository.JobMetadata metadata) { + if (metadata.getMaxProgress() == null || metadata.getCurrentProgress() == null || metadata.getMaxProgress() == 0) return 0f; + return ((float) metadata.getCurrentProgress() / metadata.getMaxProgress()) * 100f; + } + + public record AverageRuntime(long averageSeconds, int sampleCount) { + public String formattedDuration() { + long hours = averageSeconds / 3600; + long minutes = (averageSeconds % 3600) / 60; + long seconds = averageSeconds % 60; + if (hours > 0) { + return String.format("%dh %dm %ds", hours, minutes, seconds); + } else if (minutes > 0) { + return String.format("%dm %ds", minutes, seconds); + } else { + return String.format("%ds", seconds); + } + } + + public long getEstimatedSeconds() { + return (long) (averageSeconds * 1.2); + } + + public String getEstimatedDuration() { + long estimated = getEstimatedSeconds(); + long hours = estimated / 3600; + long minutes = (estimated % 3600) / 60; + if (hours > 0) { + return String.format("%dh %dm", hours, minutes); + } else { + return String.format("%d min", minutes); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java index 72f8f46a8..b2bf02107 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java @@ -1,10 +1,14 @@ package com.dedicatedcode.reitti.controller.settings; +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; import com.dedicatedcode.reitti.model.Role; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.*; import com.dedicatedcode.reitti.service.I18nService; -import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.github.kagkarlsson.scheduler.task.Task; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; @@ -13,7 +17,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; -import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; @Controller public class ManageDataController { @@ -22,27 +26,32 @@ public class ManageDataController { private final boolean deleteAllHostnameVerificationEnabled; private final TripJdbcService tripJdbcService; private final ProcessedVisitJdbcService processedVisitJdbcService; - private final ProcessingPipelineTrigger processingPipelineTrigger; + private final Task processingTask; private final RawLocationPointJdbcService rawLocationPointJdbcService; private final UserSettingsJdbcService userSettingsJdbcService; private final I18nService i18n; + private final JobSchedulingService jobScheduler; + private final SourceLocationPointJdbcService sourceLocationPointJdbcService; public ManageDataController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, @Value("${reitti.data-management.delete-all.hostname-verification.enabled:true}") boolean deleteAllHostnameVerificationEnabled, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, - ProcessingPipelineTrigger processingPipelineTrigger, + Task processingTask, RawLocationPointJdbcService rawLocationPointJdbcService, UserSettingsJdbcService userSettingsJdbcService, - I18nService i18nService) { + I18nService i18nService, + JobSchedulingService jobScheduler, SourceLocationPointJdbcService sourceLocationPointJdbcService) { this.dataManagementEnabled = dataManagementEnabled; this.deleteAllHostnameVerificationEnabled = deleteAllHostnameVerificationEnabled; this.tripJdbcService = tripJdbcService; this.processedVisitJdbcService = processedVisitJdbcService; - this.processingPipelineTrigger = processingPipelineTrigger; + this.processingTask = processingTask; this.rawLocationPointJdbcService = rawLocationPointJdbcService; this.userSettingsJdbcService = userSettingsJdbcService; this.i18n = i18nService; + this.jobScheduler = jobScheduler; + this.sourceLocationPointJdbcService = sourceLocationPointJdbcService; } @GetMapping("/settings/manage-data") @@ -77,13 +86,17 @@ public String getManageDataContent(HttpServletRequest request, Model model) { } @PostMapping("/settings/manage-data/process-visits-trips") - public String processVisitsTrips(Model model) { + public String processVisitsTrips(@AuthenticationPrincipal User user, Model model) { if (!dataManagementEnabled) { throw new RuntimeException("Data management is not enabled"); } try { - processingPipelineTrigger.start(); + jobScheduler.enqueueTask(processingTask, new TriggerProcessingEvent(user.getUsername(), null, null), + JobSchedulingService.Metadata.builder() + .user(user) + .friendlyName("Manual processing") + .jobType(JobType.LOCATION_PROCESSING).build()); model.addAttribute("successMessage", i18n.translate("data.process.success")); } catch (Exception e) { model.addAttribute("errorMessage", i18n.translate("data.process.error", e.getMessage())); @@ -101,7 +114,11 @@ public String clearAndReprocess(@AuthenticationPrincipal User user, Model model) try { clearProcessedDataExceptPlaces(user); markRawLocationPointsAsUnprocessed(user); - processingPipelineTrigger.start(); + this.jobScheduler.enqueueTask(processingTask, new TriggerProcessingEvent(user.getUsername(), null, null), + JobSchedulingService.Metadata.builder() + .user(user) + .friendlyName("Manual processing") + .jobType(JobType.LOCATION_PROCESSING).build()); model.addAttribute("successMessage", i18n.translate("data.clear.reprocess.success")); } catch (Exception e) { model.addAttribute("errorMessage", i18n.translate("data.clear.reprocess.error", e.getMessage())); @@ -158,6 +175,7 @@ private void removeAllDataExceptPlaces(User user) { tripJdbcService.deleteAllForUser(user); processedVisitJdbcService.deleteAllForUser(user); rawLocationPointJdbcService.deleteAllForUser(user); + sourceLocationPointJdbcService.deleteAllForUser(user); } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java new file mode 100644 index 000000000..ec27d6967 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java @@ -0,0 +1,233 @@ +package com.dedicatedcode.reitti.controller.settings; + +import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO; +import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; +import com.dedicatedcode.reitti.service.I18nService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Controller +@RequestMapping("/settings/map-styles") +public class MapStylesSettingsController { + private final boolean dataManagementEnabled; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final I18nService i18n; + private final ObjectMapper objectMapper; + + public MapStylesSettingsController( + @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + UserMapStyleJdbcService userMapStyleJdbcService, + I18nService i18n, ObjectMapper objectMapper) { + this.dataManagementEnabled = dataManagementEnabled; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.i18n = i18n; + this.objectMapper = objectMapper; + } + + @GetMapping + public String getPage(@AuthenticationPrincipal User user, Model model) { + model.addAttribute("activeSection", "map-styles"); + model.addAttribute("dataManagementEnabled", dataManagementEnabled); + model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); + + List persisted = userMapStyleJdbcService.findAll(user).stream().map(s -> s.toDto(user)).toList(); + model.addAttribute("styles", persisted); + model.addAttribute("activeMapStyleId", userMapStyleJdbcService.getActiveStyleId(user)); + return "settings/map-styles"; + } + + @GetMapping("/clear") + public String clearStyles() { + return "settings/map-styles :: empty-form-state"; + } + + @GetMapping("/form") + public String addFragment(@AuthenticationPrincipal User user, + @RequestParam(required = false) Long id, + Model model) { + if (id != null) { + model.addAttribute("style", userMapStyleJdbcService.findById(user, id).map(s -> s.toDto(user)).orElseThrow(() -> new IllegalArgumentException("Unknown style id: " + id))); + } + model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); + + return "settings/fragments/map-styles :: style-form"; + } + + @PostMapping("/activate") + public String activateStyle(@AuthenticationPrincipal User user, @RequestParam Long id, Model model) { + if (this.userMapStyleJdbcService.findById(user, id).isEmpty()) { + throw new IllegalStateException("Not allowed to use style with id [" + id + "]"); + } + this.userMapStyleJdbcService.setActiveStyleId(user, id); + + List persisted = userMapStyleJdbcService.findAll(user).stream().map(s -> s.toDto(user)).toList(); + model.addAttribute("styles", persisted); + model.addAttribute("activeMapStyleId", userMapStyleJdbcService.getActiveStyleId(user)); + model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); + + return "settings/map-styles :: styles-table"; + } + + @PostMapping + public String saveMapStyle(@AuthenticationPrincipal User user, @RequestParam Map params, Model model, HttpServletResponse response) { + Long id = params.get("id") != null ? Long.parseLong(params.get("id")) : null; + if (id != null && this.userMapStyleJdbcService.findById(user, id).isEmpty()) { + throw new IllegalStateException("Not allowed to use style with id [" + id + "]"); + } + String mapType = params.get("mapType"); + String name = params.get("name"); + + List errors = new ArrayList<>(); + + if (name == null || name.isBlank()) { + errors.add(i18n.translate("map.settings.dialog.map-styles.error-name-required")); + } + + if ("vector".equals(mapType)) { + String styleInputType = params.get("styleInputType"); + if ("url".equals(styleInputType)) { + String url = params.get("vectorStyleUrl"); + if (url == null || url.isBlank()) { + errors.add(i18n.translate("map.settings.dialog.map-styles.error-style-url-required")); + } + } else if ("json".equals(styleInputType)) { + String json = params.get("vectorStyleJson"); + if (json == null || json.isBlank()) { + errors.add(i18n.translate("map.settings.dialog.map-styles.error-style-json-required")); + } + try { + objectMapper.readTree(json); + } catch (Exception e) { + errors.add(i18n.translate("map.settings.dialog.map-styles.error-json")); + } + } + } else if ("raster".equals(mapType)) { + String rasterSourceInputType = params.get("rasterSourceInputType"); + if ("url-template".equals(rasterSourceInputType)) { + String template = params.get("rasterTileTemplate"); + if (template == null || template.isBlank()) { + errors.add(i18n.translate("js.map.settings.dialog.map-styles.error-tile-template-required")); + } + } else if ("json-url".equals(rasterSourceInputType)) { + String tileJsonUrl = params.get("rasterTileJsonUrl"); + if (tileJsonUrl == null || tileJsonUrl.isBlank()) { + errors.add(i18n.translate("js.map.settings.dialog.map-styles.error-tilejson-required")); + } + } + } + + if (!errors.isEmpty()) { + model.addAttribute("error", String.join("
", errors)); + response.setHeader("HX-Retarget", "#errors"); + return "settings/fragments/map-styles :: errors"; + } + + UserMapStyle mapStyle = buildFromParams(user, params); + userMapStyleJdbcService.save(user, mapStyle); + + return getPage(user, model); + } + + private UserMapStyle buildFromParams(User user, Map params) { + String id = params.get("id"); + String name = params.get("name"); + String mapType = params.get("mapType"); + String styleInputType = params.get("styleInputType"); + String rasterSourceInputType = params.get("rasterSourceInputType"); + String vectorStyleUrl = params.get("vectorStyleUrl"); + String vectorStyleJson = params.get("vectorStyleJson"); + String rasterTileTemplate = params.get("rasterTileTemplate"); + String rasterTileJsonUrl = params.get("rasterTileJsonUrl"); + String attributionOverride = params.get("attributionOverride"); + String glyphsUrlOverride = params.get("glyphsUrlOverride"); + String spriteUrlOverride = params.get("spriteUrlOverride"); + String minzoom = params.get("minzoom"); + String maxzoom = params.get("maxzoom"); + String tileSize = params.get("tileSize"); + String scheme = params.get("scheme"); + boolean proxyTiles = "on".equals(params.get("proxyTiles")); + boolean shared = "on".equals(params.get("shared")); + + // Build the data source + MapStyleDataSource dataSource = new MapStyleDataSource( + null, + mapType, + rasterTileJsonUrl, + rasterTileTemplate, + attributionOverride, + hasValue(minzoom) ? Integer.parseInt(minzoom) : null, + hasValue(maxzoom) ? Integer.parseInt(maxzoom) : null, + hasValue(tileSize) ? Integer.parseInt(tileSize) : null, + scheme, + proxyTiles + ); + + // Build vector options + MapStyleVectorOptions vectorOptions = new MapStyleVectorOptions( + attributionOverride, + glyphsUrlOverride, + spriteUrlOverride + ); + + // Determine the style URL and input based on map type + String styleUrl = null; + String styleInput = null; + + if ("vector".equals(mapType)) { + if ("url".equals(styleInputType)) { + styleUrl = vectorStyleUrl; + } else { + styleInput = vectorStyleJson; + } + } + + return new UserMapStyle( + id != null ? Long.parseLong(id) : null, + user.getId(), + name, + mapType, + styleInputType, + rasterSourceInputType, + styleInput, + styleUrl, + dataSource, + vectorOptions, + false, + shared, + null); + } + + @DeleteMapping + public String deleteMapStyle(@AuthenticationPrincipal User user, @RequestParam Long id, Model model) { + if (this.userMapStyleJdbcService.findById(user, id).isEmpty()) { + throw new IllegalStateException("Not allowed to use style with id [" + id + "]"); + } + + this.userMapStyleJdbcService.delete(id); + return getPage(user, model); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleInvalidMapStyle(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + + private boolean hasValue(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java index 66a2ed8dd..e6d5be006 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java @@ -1,7 +1,6 @@ package com.dedicatedcode.reitti.controller.settings; import com.dedicatedcode.reitti.dto.PlaceInfo; -import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent; import com.dedicatedcode.reitti.model.AvailableCountry; import com.dedicatedcode.reitti.model.Page; import com.dedicatedcode.reitti.model.PageRequest; @@ -13,7 +12,6 @@ import com.dedicatedcode.reitti.model.geocoding.GeocodingResponse; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.GeocodingResponseJdbcService; -import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; import com.dedicatedcode.reitti.repository.SignificantPlaceOverrideJdbcService; import com.dedicatedcode.reitti.service.DataCleanupService; @@ -22,9 +20,11 @@ import com.dedicatedcode.reitti.service.PlaceService; import com.dedicatedcode.reitti.service.geocoding.GeocodeResult; import com.dedicatedcode.reitti.service.geocoding.GeocodeServiceManager; -import com.dedicatedcode.reitti.service.queue.RedisQueueService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.kagkarlsson.scheduler.task.Task; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; @@ -40,54 +40,49 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.support.RedirectAttributes; -import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.stream.Collectors; -import static com.dedicatedcode.reitti.service.MessageDispatcherService.PLACE_CREATED_QUEUE; - @Controller @RequestMapping("/settings/places") public class PlacesSettingsController { private static final Logger log = LoggerFactory.getLogger(PlacesSettingsController.class); private final PlaceService placeService; private final SignificantPlaceJdbcService placeJdbcService; - private final ProcessedVisitJdbcService processedVisitJdbcService; private final SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService; private final GeocodingResponseJdbcService geocodingResponseJdbcService; private final GeocodeServiceManager geocodeServiceManager; private final GeometryFactory geometryFactory; private final I18nService i18nService; private final PlaceChangeDetectionService placeChangeDetectionService; - private final DataCleanupService dataCleanupService; + private final JobSchedulingService jobSchedulingService; + private final Task cleanupTask; private final boolean dataManagementEnabled; private final ObjectMapper objectMapper; public PlacesSettingsController(PlaceService placeService, SignificantPlaceJdbcService placeJdbcService, - ProcessedVisitJdbcService processedVisitJdbcService, SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService, GeocodingResponseJdbcService geocodingResponseJdbcService, GeocodeServiceManager geocodeServiceManager, GeometryFactory geometryFactory, I18nService i18nService, - PlaceChangeDetectionService placeChangeDetectionService, - DataCleanupService dataCleanupService, + PlaceChangeDetectionService placeChangeDetectionService, JobSchedulingService jobSchedulingService, + Task cleanupTask, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, ObjectMapper objectMapper) { this.placeService = placeService; this.placeJdbcService = placeJdbcService; - this.processedVisitJdbcService = processedVisitJdbcService; this.significantPlaceOverrideJdbcService = significantPlaceOverrideJdbcService; this.geocodingResponseJdbcService = geocodingResponseJdbcService; this.geocodeServiceManager = geocodeServiceManager; this.geometryFactory = geometryFactory; this.i18nService = i18nService; this.placeChangeDetectionService = placeChangeDetectionService; - this.dataCleanupService = dataCleanupService; + this.jobSchedulingService = jobSchedulingService; + this.cleanupTask = cleanupTask; this.dataManagementEnabled = dataManagementEnabled; this.objectMapper = objectMapper; } @@ -207,11 +202,13 @@ public String updatePlace(@PathVariable Long placeId, placeJdbcService.update(updatedPlace); log.info("Significant change detected for place [{}]. Will issue a recalculation of all affected dates", significantPlace); - List placesToRemove = placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, updatedPlace.getPolygon()); - List placesToCheck = new ArrayList<>(placesToRemove); - placesToCheck.add(updatedPlace); - List affectedDays = this.processedVisitJdbcService.getAffectedDays(placesToCheck); - this.dataCleanupService.cleanupForGeometryChange(user, placesToRemove, affectedDays); + this.jobSchedulingService.enqueueTask(cleanupTask, new DataCleanupService.TaskData(user, updatedPlace), + JobSchedulingService.Metadata.builder() + .user(user) + .friendlyName("Update Polygon of " + significantPlace.getName()) + .jobType(JobType.DATA_RECALCULATION) + .build()); + } else { placeJdbcService.update(updatedPlace); } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java index 2dee29012..e79c4cad7 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java @@ -1,12 +1,6 @@ package com.dedicatedcode.reitti.controller.settings; -import com.dedicatedcode.reitti.model.Role; -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.QueueStatsService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java index 174c10d46..91d592fb2 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java @@ -1,13 +1,17 @@ package com.dedicatedcode.reitti.controller.settings; import com.dedicatedcode.reitti.dto.ConfigurationForm; +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; import com.dedicatedcode.reitti.model.Role; import com.dedicatedcode.reitti.model.processing.DetectionParameter; import com.dedicatedcode.reitti.model.processing.RecalculationState; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.*; import com.dedicatedcode.reitti.service.VisitDetectionPreviewService; -import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; +import com.dedicatedcode.reitti.service.jobs.VisitSensitivityConfigurationRecalculationTask; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -25,7 +29,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.concurrent.CompletableFuture; +import java.util.UUID; @Controller @RequestMapping("/settings/visit-sensitivity") @@ -34,30 +38,27 @@ public class SettingsVisitSensitivityController { private static final Logger log = LoggerFactory.getLogger(SettingsVisitSensitivityController.class); private final VisitDetectionParametersJdbcService configurationService; private final VisitDetectionPreviewService visitDetectionPreviewService; - private final ProcessingPipelineTrigger processingPipelineTrigger; - private final TripJdbcService tripJdbcService; - private final ProcessedVisitJdbcService processedVisitJdbcService; + private final MessageSource messageSource; private final boolean dataManagementEnabled; + private final JobSchedulingService jobScheduler; private final RawLocationPointJdbcService rawLocationPointJdbcService; - private final SignificantPlaceJdbcService significantPlaceJdbcService; + private final Task recalculationJobTask; public SettingsVisitSensitivityController(VisitDetectionParametersJdbcService configurationService, VisitDetectionPreviewService visitDetectionPreviewService, - ProcessingPipelineTrigger processingPipelineTrigger, - TripJdbcService tripJdbcService, - ProcessedVisitJdbcService processedVisitJdbcService, MessageSource messageSource, - @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, RawLocationPointJdbcService rawLocationPointJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService) { + @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + JobSchedulingService jobScheduler, + RawLocationPointJdbcService rawLocationPointJdbcService, + Task recalculationJobTask) { this.configurationService = configurationService; this.visitDetectionPreviewService = visitDetectionPreviewService; - this.processingPipelineTrigger = processingPipelineTrigger; - this.tripJdbcService = tripJdbcService; - this.processedVisitJdbcService = processedVisitJdbcService; this.messageSource = messageSource; this.dataManagementEnabled = dataManagementEnabled; + this.jobScheduler = jobScheduler; this.rawLocationPointJdbcService = rawLocationPointJdbcService; - this.significantPlaceJdbcService = significantPlaceJdbcService; + this.recalculationJobTask = recalculationJobTask; } @GetMapping @@ -263,20 +264,15 @@ private void clearTimeRange(User user) { } needsRecalculation.forEach(dp -> this.configurationService.updateConfiguration(dp.withRecalculationState(RecalculationState.RUNNING))); - CompletableFuture.runAsync(() -> { - log.debug("Clearing all time range"); - try { - tripJdbcService.deleteAllForUser(user); - processedVisitJdbcService.deleteAllForUser(user); - significantPlaceJdbcService.deleteForUser(user); - rawLocationPointJdbcService.markAllAsUnprocessedForUser(user); - allConfigurationsForUser.forEach(config -> this.configurationService.updateConfiguration(config.withRecalculationState(RecalculationState.DONE))); - log.debug("Starting recalculation of all configurations"); - processingPipelineTrigger.start(user); - } catch (Exception e) { - log.error("Error clearing time range", e); - } - }); + + log.debug("Scheduling recalculation task"); + this.jobScheduler.enqueueTask(recalculationJobTask, + new VisitSensitivityConfigurationRecalculationTask.TaskData(user), + JobSchedulingService.Metadata.builder() + .user(user) + .friendlyName("Recalculation for changed VisitSensitivity settings") + .jobType(JobType.DATA_RECALCULATION).build()); + log.debug("Recalculation of all configurations triggered"); } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java index f1f792939..d4f6829f8 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java @@ -123,7 +123,6 @@ private String getUserContent(Model model, User currentUser) { model.addAttribute("availableLanguages", Language.values()); model.addAttribute("selectedLanguage", userSettings.getSelectedLanguage()); model.addAttribute("selectedUnitSystem", userSettings.getUnitSystem().name()); - model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap()); model.addAttribute("homeLatitude", userSettings.getHomeLatitude()); model.addAttribute("homeLongitude", userSettings.getHomeLongitude()); model.addAttribute("unitSystems", UnitSystem.values()); @@ -193,7 +192,6 @@ public String createUser(@RequestParam(required = false) String username, @RequestParam(defaultValue = "USER") Role role, @RequestParam(name = "preferred_language") Language preferredLanguage, @RequestParam(name = "unit_system", defaultValue = "METRIC") String unitSystem, - @RequestParam(defaultValue = "false") boolean preferColoredMap, @RequestParam(required = false) Double homeLatitude, @RequestParam(required = false) Double homeLongitude, @RequestParam(name = "timezone_override", required = false) String timezoneOverride, @@ -222,7 +220,6 @@ public String createUser(@RequestParam(required = false) String username, displayName, password, role, UnitSystem.valueOf(unitSystem), - preferColoredMap, preferredLanguage, homeLatitude, homeLongitude, @@ -246,7 +243,6 @@ public String createUser(@RequestParam(required = false) String username, .orElse(UserSettings.defaultSettings(createdUser.getId())); UserSettings updatedSettings = new UserSettings( existingSettings.getUserId(), - existingSettings.isPreferColoredMap(), existingSettings.getSelectedLanguage(), existingSettings.getUnitSystem(), existingSettings.getHomeLatitude(), @@ -289,7 +285,6 @@ public String updateUser(@RequestParam Long userId, @RequestParam(defaultValue = "USER") Role role, @RequestParam Language preferred_language, @RequestParam(defaultValue = "METRIC") String unit_system, - @RequestParam(defaultValue = "false") boolean preferColoredMap, @RequestParam(required = false) Double homeLatitude, @RequestParam(required = false) Double homeLongitude, @RequestParam(required = false) MultipartFile avatar, @@ -353,7 +348,6 @@ public String updateUser(@RequestParam Long userId, UnitSystem unitSystem = UnitSystem.valueOf(unit_system); UserSettings updatedSettings = new UserSettings(userId, - preferColoredMap, preferred_language, unitSystem, homeLatitude, @@ -441,7 +435,6 @@ public String getUserForm(@RequestParam(required = false) Long userId, UserSettings userSettings = userSettingsJdbcService.findByUserId(userId).orElse(UserSettings.defaultSettings(userId)); model.addAttribute("selectedLanguage", userSettings.getSelectedLanguage()); model.addAttribute("selectedUnitSystem", userSettings.getUnitSystem().name()); - model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap()); model.addAttribute("homeLatitude", userSettings.getHomeLatitude()); model.addAttribute("homeLongitude", userSettings.getHomeLongitude()); model.addAttribute("timeZoneOverride", userSettings.getTimeZoneOverride()); @@ -452,7 +445,6 @@ public String getUserForm(@RequestParam(required = false) Long userId, // Default values for new users model.addAttribute("selectedLanguage", Language.EN); model.addAttribute("selectedUnitSystem", "METRIC"); - model.addAttribute("preferColoredMap", false); model.addAttribute("selectedRole", "USER"); model.addAttribute("homeLatitude", null); model.addAttribute("homeLongitude", null); @@ -479,11 +471,9 @@ public String getUserForm(@RequestParam(required = false) Long userId, boolean hasCustomCss = userSettings != null && StringUtils.hasText(userSettings.getCustomCss()); model.addAttribute("hasCustomCss", hasCustomCss); } - - // Add default avatars to model + model.addAttribute("defaultAvatars", DEFAULT_AVATARS); - - // Add admin status to model + model.addAttribute("isAdmin", ADMIN == currentUser.getRole()); return "fragments/user-management :: user-form-page"; diff --git a/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java b/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java index a613f62ca..f18e35102 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java @@ -42,50 +42,97 @@ public ResponseEntity getMessages(Locale locale, @RequestParam("v") String ve // The logic is bundled with the data String script = """ - (function() { - window.I18N = window.I18N || {}; - // Merge new messages into the existing global object - Object.assign(window.I18N, %s); - - if (!window.t) { - window.t = function(key, params = []) { - const internalKey = "js." + key; // Automatically adds the prefix - if (!window.I18N || !(internalKey in window.I18N)) { - console.error('I18N Error: Key [' + internalKey + '] not found.'); - return '??' + internalKey + '??'; - } - - let msg = window.I18N[internalKey]; - - // 1. Handle ChoiceFormat (Plurals) - const choiceRegex = /\\{(\\d+),choice,([^}]+)\\}/g; - msg = msg.replace(choiceRegex, (match, paramIndex, choicesStr) => { - const value = parseFloat(params[paramIndex]); - if (isNaN(value)) return match; - const choices = choicesStr.split('|'); - let selectedChoice = ""; - for (const choice of choices) { - const separatorIndex = choice.search(/[#<]/); - const limit = parseFloat(choice.substring(0, separatorIndex)); - const separator = choice.charAt(separatorIndex); - const text = choice.substring(separatorIndex + 1); - if ((separator === '#' && value >= limit) || (separator === '<' && value > limit)) { - selectedChoice = text; - } + (function() { + window.I18N = window.I18N || {}; + Object.assign(window.I18N, %s); + + if (!window.t) { + window.t = function(key, params = []) { + const internalKey = "js." + key; + if (!window.I18N || !(internalKey in window.I18N)) { + console.error('I18N Error: Key [' + internalKey + '] not found.'); + return '??' + internalKey + '??'; + } + + let msg = window.I18N[internalKey]; + + // Helper: replace standard {N} and {N,number(,integer)?} placeholders + const applyParams = (str) => { + params.forEach((param, index) => { + str = str.replace( + new RegExp('\\\\{' + index + '(?:,number(?:,integer)?)?\\\\}', 'g'), + param + ); + }); + return str; + }; + + // 1. Handle ChoiceFormat (Plurals) with brace-depth-aware scanning + const choiceStart = /\\{(\\d+),choice,/g; + let result = ''; + let lastIndex = 0; + let m; + while ((m = choiceStart.exec(msg)) !== null) { + // Find the matching closing brace, respecting nested {...} + let depth = 1; + let i = choiceStart.lastIndex; + while (i < msg.length && depth > 0) { + const ch = msg[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + if (depth === 0) break; + i++; + } + if (depth !== 0) { + // Unbalanced; bail out and leave the rest as-is + break; + } + + const paramIndex = m[1]; + const choicesStr = msg.substring(choiceStart.lastIndex, i); + const value = parseFloat(params[paramIndex]); + + let replacement; + if (isNaN(value)) { + replacement = msg.substring(m.index, i + 1); + } else { + const choices = choicesStr.split('|'); + let selectedChoice = null; + for (const choice of choices) { + const separatorIndex = choice.search(/[#<]/); + if (separatorIndex < 0) continue; + const limit = parseFloat(choice.substring(0, separatorIndex)); + const separator = choice.charAt(separatorIndex); + const text = choice.substring(separatorIndex + 1); + if ((separator === '#' && value >= limit) || + (separator === '<' && value > limit)) { + selectedChoice = text; + } + } + if (selectedChoice === null) { + const first = choices[0]; + const sepIdx = first.search(/[#<]/); + selectedChoice = sepIdx >= 0 ? first.substring(sepIdx + 1) : first; + } + // Resolve nested {N}/{N,number,integer} inside the chosen branch + replacement = applyParams(selectedChoice); + } + + result += msg.substring(lastIndex, m.index) + replacement; + lastIndex = i + 1; + choiceStart.lastIndex = i + 1; + } + result += msg.substring(lastIndex); + msg = result; + + // 2. Handle remaining standard params {0}, {0,number}, {0,number,integer} + msg = applyParams(msg); + + return msg; + }; } - return selectedChoice || choices[0].substring(choices[0].search(/[#<]/) + 1); - }); - - // 2. Handle standard params {0} - params.forEach((param, index) => { - msg = msg.replace(new RegExp('\\\\{' + index + '(,number,integer|,number)?\\\\}', 'g'), param); - }); - - return msg; - }; - } - })(); - """.formatted(json); + })(); + """.formatted(json); ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok(); if ("development".equalsIgnoreCase(version)) { diff --git a/src/main/java/com/dedicatedcode/reitti/dto/MapLibreStyleDefinition.java b/src/main/java/com/dedicatedcode/reitti/dto/MapLibreStyleDefinition.java new file mode 100644 index 000000000..a9db129d5 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/MapLibreStyleDefinition.java @@ -0,0 +1,13 @@ +package com.dedicatedcode.reitti.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.Serializable; + +public record MapLibreStyleDefinition( + Long id, + String label, + String mapType, + String styleInputType, + String styleUrl, + Object capabilities) implements Serializable {} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/TimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/TimelineData.java deleted file mode 100644 index 4fe980f24..000000000 --- a/src/main/java/com/dedicatedcode/reitti/dto/TimelineData.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.dedicatedcode.reitti.dto; - -import java.util.List; - -public record TimelineData( - List users -) { -} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java index a351ce912..1058544d3 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java @@ -10,20 +10,21 @@ import java.util.Locale; public record UserSettingsDTO( - boolean preferColoredMap, Language selectedLanguage, String selectedLocale, Instant newestData, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, + @Deprecated(forRemoval = true) TilesCustomizationDTO tiles, UIMode uiMode, PhotoMode photoMode, TimeDisplayMode displayMode, TimeMode timeMode, ZoneId timezoneOverride, - String customCssUrl + String customCssUrl, + String timelineColor ) { public enum UIMode { @@ -37,6 +38,7 @@ public enum PhotoMode { DISABLED } + @Deprecated(forRemoval = true) public record TilesCustomizationDTO(String service, String attribution){} } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java new file mode 100644 index 000000000..733280186 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java @@ -0,0 +1,20 @@ +package com.dedicatedcode.reitti.dto.map; + +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; + +public record MapStyleConfigDTO( + Long id, + String label, + String mapType, + String styleInputType, + String rasterSourceInputType, + String styleUrl, + String styleInput, + boolean custom, + boolean shared, + boolean editable, + MapStyleDataSource dataSource, + MapStyleVectorOptions vectorOptions +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/DeviceTimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/DeviceTimelineData.java new file mode 100644 index 000000000..3c6fc1874 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/DeviceTimelineData.java @@ -0,0 +1,4 @@ +package com.dedicatedcode.reitti.dto.timeline; + +public record DeviceTimelineData(Long id, String name, String avatarUrl, String avatarFallback, boolean showAvatarOnMap, String color, String metadataUrl, String streamUrl) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/GroupedTimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/GroupedTimelineEntry.java new file mode 100644 index 000000000..9ae3f48b3 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/GroupedTimelineEntry.java @@ -0,0 +1,46 @@ +package com.dedicatedcode.reitti.dto.timeline; + +import com.dedicatedcode.reitti.model.geo.TransportMode; +import com.dedicatedcode.reitti.model.metadata.Mood; + +import java.time.LocalDate; +import java.util.*; + +public record GroupedTimelineEntry(UUID syntheticId, String name, String subHeadline, String href, List overview, Long visits, Long trips, List visitMoods, List tripMoods, List transportEntries, List visitEntries) implements TimelineEntry { + + @Override + public boolean isAggregated() { + return true; + } + + public record OverviewEntry(LocalDate slot, Long visits, Long trips) { + } + + public record TransportEntry(TransportMode transportMode, List parts) { + public long durationSeconds() { + return parts.stream().mapToLong(TransportModePart::durationSeconds).sum(); + } + + public Optional dominantMood() { + return parts.stream().sorted(Comparator.comparing(TransportModePart::percent)).filter(p -> p.percent > 0.5).map(TransportModePart::mood).filter(Objects::nonNull).findFirst(); + } + } + + public record VisitEntry(String name, List parts) { + public long durationSeconds() { + return parts.stream().mapToLong(VisitPart::durationSeconds).sum(); + } + + public Optional dominantMood() { + return parts.stream().sorted(Comparator.comparing(VisitPart::percent)).filter(p -> p.percent > 0.5).map(VisitPart::mood).findFirst(); + } + } + + public record MoodValue(Mood mood, long amount, long durationSeconds) { + } + + public record TransportModePart(TransportMode transportMode, Mood mood, long durationSeconds, double percent) { + } + public record VisitPart(Long placeId, String placeName, Mood mood, long durationSeconds, double percent) { + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/SingleTimelineEntry.java similarity index 89% rename from src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java rename to src/main/java/com/dedicatedcode/reitti/dto/timeline/SingleTimelineEntry.java index 2ad90115a..19fad281b 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/SingleTimelineEntry.java @@ -1,16 +1,13 @@ -package com.dedicatedcode.reitti.dto; +package com.dedicatedcode.reitti.dto.timeline; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.geo.TransportMode; +import java.sql.Time; import java.time.Instant; import java.time.ZoneId; -/** - * Inner class to represent timeline entries for the template - */ -public class TimelineEntry { - +public class SingleTimelineEntry implements TimelineEntry { public enum Type {VISIT, TRIP;} @@ -29,8 +26,8 @@ public enum Type {VISIT, TRIP;} private Double distanceMeters; private String formattedDistance; private TransportMode transportMode; + private boolean editable; - // Getters and setters public String getId() { return id; } @@ -151,4 +148,16 @@ public void setPath(String path) { this.path = path; } + public boolean isEditable() { + return editable; + } + + public void setEditable(boolean editable) { + this.editable = editable; + } + + @Override + public boolean isAggregated() { + return false; + } } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineData.java new file mode 100644 index 000000000..1782d6876 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineData.java @@ -0,0 +1,5 @@ +package com.dedicatedcode.reitti.dto.timeline; + +import java.util.List; + +public record TimelineData(List users) { } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineEntry.java new file mode 100644 index 000000000..8a0bcf9c6 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineEntry.java @@ -0,0 +1,5 @@ +package com.dedicatedcode.reitti.dto.timeline; + +public interface TimelineEntry { + boolean isAggregated(); +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/UserTimelineData.java similarity index 63% rename from src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java rename to src/main/java/com/dedicatedcode/reitti/dto/timeline/UserTimelineData.java index 3ecd4990e..d3253e272 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/UserTimelineData.java @@ -1,4 +1,4 @@ -package com.dedicatedcode.reitti.dto; +package com.dedicatedcode.reitti.dto.timeline; import java.util.List; @@ -8,9 +8,10 @@ public record UserTimelineData( String avatarFallback, String userAvatarUrl, String baseColor, - List entries, + List entries, String rawLocationPointsUrl, String processedVisitsUrl, String mapMetaDataUrl, - String mapStreamDataUrl) { + String mapStreamDataUrl, + List devices) { } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/ActionDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/ActionDto.java new file mode 100644 index 000000000..7f678230b --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/ActionDto.java @@ -0,0 +1,52 @@ +package com.dedicatedcode.reitti.dto.workbench; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ActionDto { + private int seq; + private String type; // "copy" | "delete" | "move" + private String at; // ISO‑8601 instant string + + private Map extra = new LinkedHashMap<>(); + + @JsonAnySetter + public void setExtra(String key, Object value) { + extra.put(key, value); + } + + @JsonAnyGetter + public Map getExtra() { + return extra; + } + + public int getSeq() { + return seq; + } + + public void setSeq(int seq) { + this.seq = seq; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getAt() { + return at; + } + + public void setAt(String at) { + this.at = at; + } + + public void setExtra(Map extra) { + this.extra = extra; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/DeletedPointDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/DeletedPointDto.java new file mode 100644 index 000000000..f2445a195 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/DeletedPointDto.java @@ -0,0 +1,13 @@ +package com.dedicatedcode.reitti.dto.workbench; + +public class DeletedPointDto { + private Long sourceId; // original point identifier (from database) + + public Long getSourceId() { + return sourceId; + } + + public void setSourceId(Long sourceId) { + this.sourceId = sourceId; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/EditStoreDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/EditStoreDto.java new file mode 100644 index 000000000..a4189ebea --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/EditStoreDto.java @@ -0,0 +1,33 @@ +package com.dedicatedcode.reitti.dto.workbench; + +import java.util.List; + +public class EditStoreDto { + private List patches; + private List deletedPoints; + private List movedPoints; + + public List getPatches() { + return patches; + } + + public void setPatches(List patches) { + this.patches = patches; + } + + public List getDeletedPoints() { + return deletedPoints; + } + + public void setDeletedPoints(List deletedPoints) { + this.deletedPoints = deletedPoints; + } + + public List getMovedPoints() { + return movedPoints; + } + + public void setMovedPoints(List movedPoints) { + this.movedPoints = movedPoints; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/FinalStateDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/FinalStateDto.java new file mode 100644 index 000000000..139552f82 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/FinalStateDto.java @@ -0,0 +1,23 @@ +package com.dedicatedcode.reitti.dto.workbench; + +public class FinalStateDto { + private long tStart; + private long tEnd; + + + public long gettStart() { + return tStart; + } + + public void settStart(long tStart) { + this.tStart = tStart; + } + + public long gettEnd() { + return tEnd; + } + + public void settEnd(long tEnd) { + this.tEnd = tEnd; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/MovedPointDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/MovedPointDto.java new file mode 100644 index 000000000..18f228b7d --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/MovedPointDto.java @@ -0,0 +1,31 @@ +package com.dedicatedcode.reitti.dto.workbench; + +public class MovedPointDto { + private Long sourceId; + private double lat; + private double lng; + + public Long getSourceId() { + return sourceId; + } + + public void setSourceId(Long sourceId) { + this.sourceId = sourceId; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/PatchDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/PatchDto.java new file mode 100644 index 000000000..c44cf0103 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/PatchDto.java @@ -0,0 +1,50 @@ +package com.dedicatedcode.reitti.dto.workbench; + +public class PatchDto { + private int seq; + private long tStart; // epoch milliseconds + private long tEnd; // epoch milliseconds + private String deviceId; // nullable – null for default device, non‑null for specific device IDs + + public int getSeq() { + return seq; + } + + public void setSeq(int seq) { + this.seq = seq; + } + + public long gettStart() { + return tStart; + } + + public void settStart(long tStart) { + this.tStart = tStart; + } + + public long gettEnd() { + return tEnd; + } + + public void settEnd(long tEnd) { + this.tEnd = tEnd; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + @Override + public String toString() { + return "PatchDto{" + + "seq=" + seq + + ", tStart=" + tStart + + ", tEnd=" + tEnd + + ", deviceId='" + deviceId + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitRequest.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitRequest.java new file mode 100644 index 000000000..8dcd3a13f --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitRequest.java @@ -0,0 +1,36 @@ +package com.dedicatedcode.reitti.dto.workbench; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WorkbenchCommitRequest { + + private EditStoreDto editStore; + private List actions; + private FinalStateDto finalState; + + public EditStoreDto getEditStore() { + return editStore; + } + + public void setEditStore(EditStoreDto editStore) { + this.editStore = editStore; + } + + public List getActions() { + return actions; + } + + public void setActions(List actions) { + this.actions = actions; + } + + public FinalStateDto getFinalState() { + return finalState; + } + + public void setFinalState(FinalStateDto finalState) { + this.finalState = finalState; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitResponse.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitResponse.java new file mode 100644 index 000000000..fd8822386 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitResponse.java @@ -0,0 +1,24 @@ +package com.dedicatedcode.reitti.dto.workbench; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WorkbenchCommitResponse { + private final boolean success; + private final String message; + private final String errorCode; + + public WorkbenchCommitResponse(boolean success, String message) { + this(success, message, null); + } + + public WorkbenchCommitResponse(boolean success, String message, String errorCode) { + this.success = success; + this.message = message; + this.errorCode = errorCode; + } + + public boolean isSuccess() { return success; } + public String getMessage() { return message; } + public String getErrorCode() { return errorCode; } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java b/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java index 74b8a40da..4ed645c0a 100644 --- a/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java +++ b/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java @@ -5,7 +5,6 @@ import java.io.Serializable; import java.time.Instant; -import java.util.Objects; import java.util.UUID; public class LocationProcessEvent implements Serializable { @@ -14,6 +13,7 @@ public class LocationProcessEvent implements Serializable { private final Instant latest; private final String previewId; private final String traceId; + private final UUID parentJobId; @JsonCreator public LocationProcessEvent( @@ -21,12 +21,14 @@ public LocationProcessEvent( @JsonProperty("earliest") Instant earliest, @JsonProperty("latest") Instant latest, @JsonProperty("previewId") String previewId, - @JsonProperty("trace-id") String traceId) { + @JsonProperty("trace-id") String traceId, + @JsonProperty("parent-job-id") UUID parentJobId) { this.username = username; this.earliest = earliest; this.latest = latest; this.previewId = previewId; this.traceId = traceId; + this.parentJobId = parentJobId; } public String getUsername() { @@ -49,6 +51,10 @@ public String getTraceId() { return traceId; } + public UUID getParentJobId() { + return parentJobId; + } + @Override public String toString() { return "LocationProcessEvent{" + diff --git a/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java b/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java index eb904517e..ac082b02e 100644 --- a/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java +++ b/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java @@ -1,17 +1,28 @@ package com.dedicatedcode.reitti.event; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.dedicatedcode.reitti.service.JobContext; import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +public final class SignificantPlaceCreatedEvent extends JobContext implements Serializable { + private final String username; + private final String previewId; + private final Long placeId; + private final Double latitude; + private final Double longitude; + private final String traceId; -public record SignificantPlaceCreatedEvent(String username, String previewId, Long placeId, Double latitude, - Double longitude, String traceId) implements Serializable { public SignificantPlaceCreatedEvent(String username, String previewId, Long placeId, Double latitude, Double longitude, - @JsonProperty("trace-id") String traceId) { + String traceId, + UUID jobId, + UUID parentJobId) { + super(jobId, parentJobId); this.username = username; this.previewId = previewId; this.placeId = placeId; @@ -20,6 +31,15 @@ public SignificantPlaceCreatedEvent(String username, this.traceId = traceId; } + public SignificantPlaceCreatedEvent(String username, + String previewId, + Long placeId, + Double latitude, + Double longitude, + String traceId) { + this(username, previewId, placeId, latitude, longitude, traceId, null, null); + } + @Override public String toString() { return "SignificantPlaceCreatedEvent{" + @@ -31,4 +51,38 @@ public String toString() { ", traceId='" + traceId + '\'' + '}'; } + + public String username() { + return username; + } + + public String previewId() { + return previewId; + } + + public Long placeId() { + return placeId; + } + + public Double latitude() { + return latitude; + } + + public Double longitude() { + return longitude; + } + + public String traceId() { + return traceId; + } + + @Override + public SignificantPlaceCreatedEvent withJobId(UUID jobId) { + return new SignificantPlaceCreatedEvent(username, previewId, placeId, latitude, longitude, traceId, jobId, parentJobId); + } + + @Override + public SignificantPlaceCreatedEvent withParentJobId(UUID parentJobId) { + return new SignificantPlaceCreatedEvent(username, previewId, placeId, latitude, longitude, traceId, jobId, parentJobId); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java b/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java index 7ad579254..6873c77a3 100644 --- a/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java +++ b/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java @@ -1,23 +1,31 @@ package com.dedicatedcode.reitti.event; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.dedicatedcode.reitti.service.JobContext; import java.io.Serializable; import java.time.Instant; import java.util.UUID; -public class TriggerProcessingEvent implements Serializable { +public class TriggerProcessingEvent extends JobContext implements Serializable { private final String username; private final String previewId; private final Instant receivedAt; private final String traceId; - @JsonCreator public TriggerProcessingEvent( - @JsonProperty("username") String username, + String username, String previewId, - @JsonProperty("trace-id") String traceId) { + String traceId) { + this(username, previewId, traceId, null, null); + } + + public TriggerProcessingEvent( + String username, + String previewId, + String traceId, + UUID jobId, + UUID parentJobId) { + super(jobId, parentJobId); this.username = username; this.previewId = previewId; this.traceId = traceId; @@ -40,6 +48,16 @@ public String getTraceId() { return traceId; } + @Override + public TriggerProcessingEvent withJobId(UUID jobId) { + return new TriggerProcessingEvent(username, previewId, traceId, jobId, parentJobId); + } + + @Override + public TriggerProcessingEvent withParentJobId(UUID parentJobId) { + return new TriggerProcessingEvent(username, previewId, traceId, jobId, parentJobId); + } + @Override public String toString() { return "TriggerProcessingEvent{" + diff --git a/src/main/java/com/dedicatedcode/reitti/model/ClusteredPoint.java b/src/main/java/com/dedicatedcode/reitti/model/ClusteredPoint.java deleted file mode 100644 index c51c390bd..000000000 --- a/src/main/java/com/dedicatedcode/reitti/model/ClusteredPoint.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.dedicatedcode.reitti.model; - -import com.dedicatedcode.reitti.model.geo.RawLocationPoint; - -public class ClusteredPoint { - private final RawLocationPoint point; - private final Integer clusterId; - - public ClusteredPoint(RawLocationPoint point, Integer clusterId) { - this.point = point; - this.clusterId = clusterId; - } - - public RawLocationPoint getPoint() { - return point; - } - - public Integer getClusterId() { - return clusterId; - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/model/Language.java b/src/main/java/com/dedicatedcode/reitti/model/Language.java index 2032f2be8..39ec4d8a8 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/Language.java +++ b/src/main/java/com/dedicatedcode/reitti/model/Language.java @@ -12,9 +12,12 @@ public enum Language { RU(Locale.of("ru"), "language.russian", "\uD83C\uDDF7\uD83C\uDDFA"), JA(Locale.of("ja"), "language.japanese", "\uD83C\uDDEF\uD83C\uDDF5"), PT_BR(Locale.of("pt", "br"), "language.brazilian_portuguese", "\uD83C\uDDE7\uD83C\uDDF7"), + PT(Locale.of("pt"), "language.portuguese", "\uD83C\uDDF5\uD83C\uDDF9"), + KO(Locale.of("ko"), "language.korean", "\uD83C\uDDF0\uD83C\uDDF7"), TR(Locale.of("tr"), "language.turkish", "\uD83C\uDDF9\uD83C\uDDF7"), UK(Locale.of("uk"), "language.ukrainian", "\uD83C\uDDFA\uD83C\uDDE6"), ZH_CN(Locale.of("zh", "cn"), "language.chinese", "\uD83C\uDDE8\uD83C\uDDF3"), + TW_CN(Locale.of("tw", "cn"), "language.chinese_traditional", "\uD83C\uDDE8\uD83C\uDDF3"), ES(Locale.of("es"), "language.spanish", "\uD83C\uDDEA\uD83C\uDDF8"); private final Locale locale; diff --git a/src/main/java/com/dedicatedcode/reitti/model/devices/Device.java b/src/main/java/com/dedicatedcode/reitti/model/devices/Device.java new file mode 100644 index 000000000..23a4b3203 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/devices/Device.java @@ -0,0 +1,12 @@ +package com.dedicatedcode.reitti.model.devices; + +import java.io.Serializable; +import java.time.Instant; + +public record Device(Long id, String name, boolean enabled, boolean showOnMap, boolean showAvatarOnMap, String color, boolean defaultDevice, Instant createdAt, + Instant updatedAt, Long version) implements Serializable { + + public Device withDefaultDevice(boolean defaultDevice) { + return new Device(id, name, enabled, showOnMap, showAvatarOnMap, color, defaultDevice, createdAt, updatedAt, version); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java index 7978ed635..ad0e039c6 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java @@ -48,6 +48,12 @@ public static double distanceInMeters(RawLocationPoint p1, RawLocationPoint p2) p2.getLatitude(), p2.getLongitude()); } + public static double distanceInMeters(SourceLocationPoint p1, SourceLocationPoint p2) { + return distanceInMeters( + p1.getLatitude(), p1.getLongitude(), + p2.getLatitude(), p2.getLongitude()); + } + /** * Converts a distance in meters to degrees of latitude and longitude at a given position. * The conversion varies based on the latitude because longitude degrees get closer together as you move away from the equator. diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java b/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java index a6cf99b9e..1f0e86478 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.model.geo; import java.time.Instant; +import java.util.Map; import java.util.Objects; public class ProcessedVisit { @@ -10,18 +11,20 @@ public class ProcessedVisit { private final Instant startTime; private final Instant endTime; private final Long durationSeconds; + private final Map metadata; private final Long version; - public ProcessedVisit(SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds) { - this(null, place, startTime, endTime, durationSeconds, 1L); + public ProcessedVisit(SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds, Map metadata) { + this(null, place, startTime, endTime, durationSeconds, metadata, 1L); } - public ProcessedVisit(Long id, SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds, Long version) { + public ProcessedVisit(Long id, SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds, Map metadata, Long version) { this.id = id; this.place = place; this.startTime = startTime; this.endTime = endTime; this.durationSeconds = durationSeconds; + this.metadata = metadata; this.version = version; } @@ -49,12 +52,20 @@ public Long getVersion() { return this.version; } + public Map getMetadata() { + return metadata; + } + public ProcessedVisit withId(Long id) { - return new ProcessedVisit(id, this.place, this.startTime, this.endTime, this.durationSeconds, this.version); + return new ProcessedVisit(id, this.place, this.startTime, this.endTime, this.durationSeconds, metadata, this.version); } public ProcessedVisit withVersion(long version) { - return new ProcessedVisit(this.id, this.place, this.startTime, this.endTime, this.durationSeconds, version); + return new ProcessedVisit(this.id, this.place, this.startTime, this.endTime, this.durationSeconds, metadata, version); + } + + public ProcessedVisit withMetadata(Map metadata) { + return new ProcessedVisit(this.id, this.place, this.startTime, this.endTime, this.durationSeconds, metadata, this.version); } @Override diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java b/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java index d6ca51850..b06e2ca4f 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java @@ -6,39 +6,37 @@ public class RawLocationPoint { private final Long id; + private final Long sourceId; private final Instant timestamp; private final Double accuracyMeters; private final Double elevationMeters; private final GeoPoint geom; private final boolean processed; private final boolean synthetic; - private final boolean invalid; - private final boolean ignored; private final Long version; public RawLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters) { - this(null, timestamp, geom, accuracyMeters, null, false, false, false, false, null); + this(null, null, timestamp, geom, accuracyMeters, null, false, false, null); } public RawLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters) { - this(null, timestamp, geom, accuracyMeters, elevationMeters, false, false, false, false, null); + this(null, null, timestamp, geom, accuracyMeters, elevationMeters, false, false, null); } public RawLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, boolean processed, Long version) { - this(id, timestamp, geom, accuracyMeters, elevationMeters, processed, false, false, false, version); + this(id, null, timestamp, geom, accuracyMeters, elevationMeters, processed, false, version); } - public RawLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, boolean processed, boolean synthetic, boolean ignored, boolean invalid, Long version) { + public RawLocationPoint(Long id, Long sourceId, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, boolean processed, boolean synthetic, Long version) { this.id = id; + this.sourceId = sourceId; this.timestamp = timestamp; this.geom = geom; this.accuracyMeters = accuracyMeters; this.elevationMeters = elevationMeters; this.processed = processed; this.synthetic = synthetic; - this.invalid = invalid; - this.ignored = ignored; this.version = version; } @@ -46,6 +44,10 @@ public Long getId() { return id; } + public Long getSourceId() { + return sourceId; + } + public Instant getTimestamp() { return timestamp; } @@ -78,32 +80,16 @@ public boolean isSynthetic() { return synthetic; } - public boolean isInvalid() { - return invalid; - } - - public boolean isIgnored() { - return ignored; - } - public RawLocationPoint markProcessed() { - return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, true, synthetic, ignored, invalid, version); + return new RawLocationPoint(id, sourceId, timestamp, geom, accuracyMeters, elevationMeters, true, synthetic, version); } public RawLocationPoint markAsSynthetic() { - return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, true, ignored, invalid, version); + return new RawLocationPoint(id, sourceId, timestamp, geom, accuracyMeters, elevationMeters, processed, true, version); } - public RawLocationPoint markAsIgnored() { - return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, true, invalid, version); - } - - public RawLocationPoint markAsInvalid() { - return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, true, invalid, version); - } - public RawLocationPoint withId(Long id) { - return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, ignored, invalid, version); + return new RawLocationPoint(id, sourceId, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, version); } public Long getVersion() { diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/SourceLocationPoint.java b/src/main/java/com/dedicatedcode/reitti/model/geo/SourceLocationPoint.java new file mode 100644 index 000000000..5c0adb8c4 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/SourceLocationPoint.java @@ -0,0 +1,122 @@ +package com.dedicatedcode.reitti.model.geo; + +import java.time.Instant; +import java.util.Objects; + +public class SourceLocationPoint { + + private final Long id; + private final Instant timestamp; + private final Double accuracyMeters; + private final Double elevationMeters; + private final GeoPoint geom; + private final boolean invalid; + private final Status status; + + public SourceLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters) { + this(null, timestamp, geom, accuracyMeters, null, Status.VALID, false); + } + + public SourceLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters) { + this(null, timestamp, geom, accuracyMeters, elevationMeters, Status.VALID, false); + } + + public SourceLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters) { + this(id, timestamp, geom, accuracyMeters, elevationMeters, Status.VALID, false); + } + + public SourceLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, Status status, boolean invalid) { + this.id = id; + this.timestamp = timestamp; + this.geom = geom; + this.accuracyMeters = accuracyMeters; + this.elevationMeters = elevationMeters; + this.invalid = invalid; + this.status = status; + } + + public Long getId() { + return id; + } + + public Instant getTimestamp() { + return timestamp; + } + + public Double getLatitude() { + return this.geom.latitude(); + } + + public Double getLongitude() { + return this.geom.longitude(); + } + + public Double getAccuracyMeters() { + return accuracyMeters; + } + + public Double getElevationMeters() { + return elevationMeters; + } + + public GeoPoint getGeom() { + return geom; + } + + public boolean isInvalid() { + return invalid; + } + + public Status getStatus() { + return status; + } + + public SourceLocationPoint markAsIgnored() { + return new SourceLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, Status.IGNORED_BY_SYSTEM, invalid); + } + + public SourceLocationPoint markAsInvalid() { + return new SourceLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, status, true); + } + + public SourceLocationPoint withId(Long id) { + return new SourceLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, status, invalid); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + SourceLocationPoint that = (SourceLocationPoint) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + public enum Status { + VALID(0L), + IGNORED_BY_USER(1L), + IGNORED_BY_SYSTEM(2L); + + private final long dbValue; + + Status(long dbValue) { + this.dbValue = dbValue; + } + + public static Status fromDbValue(long dbValue) { + for (Status status : Status.values()) { + if (status.dbValue == dbValue) { + return status; + } + } + return null; + } + + public long getDbValue() { + return dbValue; + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java b/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java index 93e017000..dc29ecd80 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.model.geo; import java.time.Instant; +import java.util.Map; import java.util.Objects; public class Trip { @@ -14,13 +15,14 @@ public class Trip { private final TransportMode transportModeInferred; private final ProcessedVisit startVisit; private final ProcessedVisit endVisit; + private final Map metadata; private final Long version; - public Trip(Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit) { - this(null, startTime, endTime, durationSeconds, estimatedDistanceMeters, travelledDistanceMeters, transportModeInferred, startVisit, endVisit, 1L); + public Trip(Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Map metadata) { + this(null, startTime, endTime, durationSeconds, estimatedDistanceMeters, travelledDistanceMeters, transportModeInferred, startVisit, endVisit, metadata, 1L); } - public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Long version) { + public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Map metadata, Long version) { this.id = id; this.startTime = startTime; this.endTime = endTime; @@ -30,6 +32,7 @@ public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, D this.transportModeInferred = transportModeInferred; this.startVisit = startVisit; this.endVisit = endVisit; + this.metadata = metadata; this.version = version; } @@ -73,16 +76,24 @@ public Long getVersion() { return version; } + public Map getMetadata() { + return metadata; + } + public Trip withId(Long id) { - return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, this.version); + return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, metadata, this.version); } public Trip withTransportMode(TransportMode mode) { - return new Trip(this.id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, mode, this.startVisit, this.endVisit, this.version); + return new Trip(this.id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, mode, this.startVisit, this.endVisit, metadata, this.version); } public Trip withVersion(long version) { - return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, version); + return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, metadata, version); + } + + public Trip withMetadata(Map metadata) { + return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, metadata, this.version); } @Override @@ -96,5 +107,4 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hashCode(id); } - } diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java b/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java index 0059a5005..54341bbdf 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java @@ -1,5 +1,7 @@ package com.dedicatedcode.reitti.model.geo; +import com.dedicatedcode.reitti.model.metadata.MemoryMetadata; + import java.time.Instant; import java.util.Objects; diff --git a/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java b/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java index e56658fad..f9bcaf54a 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java +++ b/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java @@ -10,21 +10,23 @@ public class OwnTracksRecorderIntegration { private final String deviceId; private final String authUsername; private final String authPassword; + private final Long reittiDeviceId; private final boolean enabled; private final Instant lastSuccessfulFetch; private final Long version; - public OwnTracksRecorderIntegration(String baseUrl, String username, String deviceId, boolean enabled, String authUsername, String authPassword) { - this(null, baseUrl, username, deviceId, authPassword, authUsername, enabled, null, null); + public OwnTracksRecorderIntegration(String baseUrl, String username, String deviceId, boolean enabled, String authUsername, String authPassword, Long reittiDeviceId) { + this(null, baseUrl, username, deviceId, authPassword, authUsername, reittiDeviceId, enabled, null, null); } - public OwnTracksRecorderIntegration(Long id, String baseUrl, String username, String deviceId, String authUsername, String authPassword, boolean enabled, Instant lastSuccessfulFetch, Long version) { + public OwnTracksRecorderIntegration(Long id, String baseUrl, String username, String deviceId, String authUsername, String authPassword, Long reittiDeviceId, boolean enabled, Instant lastSuccessfulFetch, Long version) { this.id = id; this.baseUrl = baseUrl; this.username = username; this.deviceId = deviceId; this.authUsername = authUsername; this.authPassword = authPassword; + this.reittiDeviceId = reittiDeviceId; this.enabled = enabled; this.lastSuccessfulFetch = lastSuccessfulFetch; this.version = version; @@ -54,6 +56,10 @@ public String getAuthPassword() { return authPassword; } + public Long getReittiDeviceId() { + return reittiDeviceId; + } + public boolean isEnabled() { return enabled; } @@ -67,18 +73,22 @@ public Long getVersion() { } public OwnTracksRecorderIntegration withEnabled(boolean enabled) { - return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, enabled, this.lastSuccessfulFetch, this.version); + return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, enabled, this.lastSuccessfulFetch, this.version); } public OwnTracksRecorderIntegration withId(Long id) { - return new OwnTracksRecorderIntegration(id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, this.enabled, this.lastSuccessfulFetch, this.version); + return new OwnTracksRecorderIntegration(id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, this.lastSuccessfulFetch, this.version); } public OwnTracksRecorderIntegration withVersion(Long version) { - return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, this.enabled, this.lastSuccessfulFetch, version); + return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, this.lastSuccessfulFetch, version); } public OwnTracksRecorderIntegration withLastSuccessfulFetch(Instant lastSuccessfulFetch) { - return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, this.enabled, lastSuccessfulFetch, this.version); + return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, lastSuccessfulFetch, this.version); + } + + public OwnTracksRecorderIntegration withReittiDeviceId(Long reittiDeviceId) { + return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, this.lastSuccessfulFetch, this.version); } } diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java new file mode 100644 index 000000000..1cf1dcccd --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java @@ -0,0 +1,19 @@ +package com.dedicatedcode.reitti.model.map; + +public record MapStyleDataSource( + String sourceId, + String type, + String tileJsonUrl, + String tileUrlTemplate, + String attribution, + Integer minzoom, + Integer maxzoom, + Integer tileSize, + String scheme, + boolean proxyTiles +) { + public MapStyleDataSource withProxyTiles(boolean proxyTiles) { + return new MapStyleDataSource(sourceId, type, tileJsonUrl, tileUrlTemplate, attribution, + minzoom, maxzoom, tileSize, scheme, proxyTiles); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java new file mode 100644 index 000000000..a1b894876 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java @@ -0,0 +1,8 @@ +package com.dedicatedcode.reitti.model.map; + +public record MapStyleVectorOptions( + String attributionOverride, + String glyphsUrlOverride, + String spriteUrlOverride +) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java b/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java new file mode 100644 index 000000000..00639db47 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java @@ -0,0 +1,40 @@ +package com.dedicatedcode.reitti.model.map; + +import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO; +import com.dedicatedcode.reitti.model.security.User; + +public record UserMapStyle( + Long id, + Long userId, + String name, + String mapType, + String styleInputType, + String rasterSourceInputType, + String styleJson, + String styleUrl, + MapStyleDataSource dataSource, + MapStyleVectorOptions vectorOptions, + boolean defaultStyle, + boolean shared, + Long version +) { + public String styleInput() { + return styleJson != null ? styleJson : styleUrl; + } + + public MapStyleConfigDTO toDto(User user) { + return new MapStyleConfigDTO( + id, + name(), + mapType(), + styleInputType(), + rasterSourceInputType(), + styleUrl, + styleInput(), + !defaultStyle, + shared(), + !defaultStyle && userId().equals(user.getId()), + dataSource(), + vectorOptions()); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java b/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java index e1c1ef369..fef54910b 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java +++ b/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java @@ -2,6 +2,7 @@ import com.dedicatedcode.reitti.model.geo.ProcessedVisit; import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import org.springframework.context.i18n.LocaleContextHolder; import java.time.Duration; import java.time.Instant; @@ -25,9 +26,9 @@ public static MemoryVisit create(ProcessedVisit visit) { if (place.getCity() != null && !place.getCity().isBlank()) { name = place.getCity(); } else { - name = String.format("%.4f, %.4f", - place.getLatitudeCentroid(), - place.getLongitudeCentroid()); + name = String.format(LocaleContextHolder.getLocale(), "%.4f, %.4f", + place.getLatitudeCentroid(), + place.getLongitudeCentroid()); } } return new MemoryVisit(null, true, name, visit.getStartTime(), visit.getEndTime(), visit.getPlace().getLatitudeCentroid(), visit.getPlace().getLongitudeCentroid(), visit.getPlace().getTimezone()); diff --git a/src/main/java/com/dedicatedcode/reitti/model/metadata/MemoryMetadata.java b/src/main/java/com/dedicatedcode/reitti/model/metadata/MemoryMetadata.java new file mode 100644 index 000000000..4ecddda69 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/metadata/MemoryMetadata.java @@ -0,0 +1,76 @@ +package com.dedicatedcode.reitti.model.metadata; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MemoryMetadata { + + private final Map properties = new HashMap<>(); + private final Instant startTime; + private final Instant endTime; + + public static MemoryMetadata empty() { + return new MemoryMetadata(null, null); + } + + public MemoryMetadata(Instant startTime, Instant endTime) { + this.startTime = startTime; + this.endTime = endTime; + } + + // --- Time Envelope Getters/Setters --- + public Instant getStartTime() { return startTime; } + + public Instant getEndTime() { return endTime; } + + public Mood getMood() { + Object val = properties.get("mood"); + return val == null ? null : Mood.fromString(val.toString()); + } + + public void setMood(Mood mood) { + if (mood == null) properties.remove("mood"); + else properties.put("mood", mood.name()); + } + + public String getDescription() { return (String) properties.get("description"); } + public void setDescription(String d) { + if (d == null) properties.remove("description"); + else properties.put("description", d); + } + + public String getReason() { return (String) properties.get("reason"); } + public void setReason(String r) { + if (r == null) properties.remove("reason"); + else properties.put("reason", r); + } + + @SuppressWarnings("unchecked") + public List getTags() { + Object tags = properties.get("tags"); + if (!(tags instanceof List)) { + List newList = new ArrayList<>(); + properties.put("tags", newList); + return newList; + } + return (List) tags; + } + + public void setTags(List tags) { + if (tags == null) properties.remove("tags"); + else properties.put("tags", tags); + } + + public Map getProperties() { return properties; } + + public void setProperty(String key, Object value) { this.properties.put(key, value); } + + public void setProperties(Map properties) { + this.properties.clear(); + this.properties.putAll(properties); + } + +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/model/metadata/Mood.java b/src/main/java/com/dedicatedcode/reitti/model/metadata/Mood.java new file mode 100644 index 000000000..16a1261a3 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/metadata/Mood.java @@ -0,0 +1,24 @@ +package com.dedicatedcode.reitti.model.metadata; + +public enum Mood { + HAPPY("\uD83D\uDE0A"), + RELAXED("\uD83D\uDE0C"), + ADVENTUROUS("\uD83E\uDD20"), + TIRED("\uD83D\uDE34"), + STRESSED("\uD83D\uDE2B"); + + private final String icon; + + Mood(String icon) { + this.icon = icon; + } + + public String getIcon() { + return icon; + } + + public static Mood fromString(String value) { + if (value == null || value.trim().isEmpty()) return null; + return Mood.valueOf(value.toUpperCase()); + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java b/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java index 0bca12bcd..49819b024 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java +++ b/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java @@ -1,5 +1,7 @@ package com.dedicatedcode.reitti.model.security; +import com.dedicatedcode.reitti.model.devices.Device; + import java.time.Instant; import java.util.UUID; @@ -8,7 +10,9 @@ public class ApiToken { private final Long id; private final String token; - + + private final Device device; + private final User user; private final String name; @@ -18,19 +22,23 @@ public class ApiToken { private final Instant lastUsedAt; public ApiToken(User user, String name) { - this(null, null, user, name, null, null); + this(user, name, null); } - - public ApiToken(Long id, String token, User user, String name, Instant createdAt, Instant lastUsedAt) { + + public ApiToken(User user, String name, Device device) { + this(null, null, user, device, name, null, null); + } + + public ApiToken(Long id, String token, User user, Device device, String name, Instant createdAt, Instant lastUsedAt) { this.id = id; this.token = token != null ? token : UUID.randomUUID().toString(); this.user = user; + this.device = device; this.name = name; this.createdAt = createdAt != null ? createdAt : Instant.now(); this.lastUsedAt = lastUsedAt; } - - // Getters + public Long getId() { return id; } @@ -42,7 +50,11 @@ public String getToken() { public User getUser() { return user; } - + + public Device getDevice() { + return device; + } + public String getName() { return name; } @@ -57,6 +69,10 @@ public Instant getLastUsedAt() { // Wither method public ApiToken withLastUsedAt(Instant lastUsedAt) { - return new ApiToken(this.id, this.token, this.user, this.name, this.createdAt, lastUsedAt); + return new ApiToken(this.id, this.token, this.user, this.device, this.name, this.createdAt, lastUsedAt); + } + + public ApiToken withDevice(Device device) { + return new ApiToken(this.id, this.token, this.user, device, this.name, this.createdAt, this.lastUsedAt); } } diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java b/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java index 6353a0958..2a0d45656 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java +++ b/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java @@ -2,5 +2,5 @@ import java.time.Instant; -public record ApiTokenUsage(String token, String name, Instant at, String endpoint, String ip) { +public record ApiTokenUsage(String token, String name, String device, Instant at, String endpoint, String ip) { } diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/DeviceTokenUser.java b/src/main/java/com/dedicatedcode/reitti/model/security/DeviceTokenUser.java new file mode 100644 index 000000000..d71235487 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/security/DeviceTokenUser.java @@ -0,0 +1,17 @@ +package com.dedicatedcode.reitti.model.security; + +import com.dedicatedcode.reitti.model.devices.Device; + +import java.util.Optional; + +public class DeviceTokenUser extends User { + private final Device device; + public DeviceTokenUser(User user, Device device) { + super(user.getId(), user.getUsername(), user.getPassword(), user.getDisplayName(), user.getProfileUrl(), user.getExternalId(), user.getRole(), user.getVersion()); + this.device = device; + } + + public Optional getDevice() { + return Optional.ofNullable(device); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java b/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java index 3efe0891a..f47cac155 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java +++ b/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java @@ -13,7 +13,6 @@ public class UserSettings implements Serializable { private final Long userId; - private final boolean preferColoredMap; private final Language selectedLanguage; private final UnitSystem unitSystem; private final Double homeLatitude; @@ -26,9 +25,8 @@ public class UserSettings implements Serializable { private final String color; private final Long version; - public UserSettings(Long userId, boolean preferColoredMap, Language selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, ZoneId timeZoneOverride, TimeDisplayMode timeDisplayMode, TimeMode timeMode, String customCss, Instant latestData, String color, Long version) { + public UserSettings(Long userId, Language selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, ZoneId timeZoneOverride, TimeDisplayMode timeDisplayMode, TimeMode timeMode, String customCss, Instant latestData, String color, Long version) { this.userId = userId; - this.preferColoredMap = preferColoredMap; this.selectedLanguage = selectedLanguage; this.unitSystem = unitSystem; this.homeLatitude = homeLatitude; @@ -43,16 +41,12 @@ public UserSettings(Long userId, boolean preferColoredMap, Language selectedLang } public static UserSettings defaultSettings(Long userId) { - return new UserSettings(userId, false, Language.EN, UnitSystem.METRIC, null, null, null, TimeDisplayMode.DEFAULT, TimeMode.TWENTY_FOUR_HOUR, null, null, "#f1ba63", null); + return new UserSettings(userId, Language.EN, UnitSystem.METRIC, null, null, null, TimeDisplayMode.DEFAULT, TimeMode.TWENTY_FOUR_HOUR, null, null, "#f1ba63", null); } public Long getUserId() { return userId; } - public boolean isPreferColoredMap() { - return preferColoredMap; - } - public Language getSelectedLanguage() { return selectedLanguage; } @@ -94,7 +88,7 @@ public String getCustomCss() { } public UserSettings withHomeCoordinates(Double homeLatitude, Double homeLongitude) { - return new UserSettings(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, this.timeMode, customCss, latestData, color, version); + return new UserSettings(userId, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, this.timeMode, customCss, latestData, color, version); } public String getColor() { @@ -106,8 +100,7 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserSettings that = (UserSettings) o; - return preferColoredMap == that.preferColoredMap && - Objects.equals(userId, that.userId) && + return Objects.equals(userId, that.userId) && Objects.equals(selectedLanguage, that.selectedLanguage) && Objects.equals(unitSystem, that.unitSystem) && Objects.equals(homeLatitude, that.homeLatitude) && @@ -122,14 +115,13 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, version); + return Objects.hash(userId, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, version); } @Override public String toString() { return "UserSettings{" + "userId=" + userId + - ", preferColoredMap=" + preferColoredMap + ", selectedLanguage='" + selectedLanguage + '\'' + ", unitSystem=" + unitSystem + ", homeLatitude=" + homeLatitude + @@ -144,6 +136,6 @@ public String toString() { } public UserSettings withVersion(long version) { - return new UserSettings(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, color, version); + return new UserSettings(userId, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, color, version); } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java index c903c7f3a..3bfe037bc 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java @@ -1,8 +1,9 @@ package com.dedicatedcode.reitti.repository; +import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.ApiToken; import com.dedicatedcode.reitti.model.security.ApiTokenUsage; -import com.dedicatedcode.reitti.model.Role; import com.dedicatedcode.reitti.model.security.User; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -16,7 +17,6 @@ import java.util.Optional; @Service -@Transactional public class ApiTokenJdbcService { private final JdbcTemplate jdbcTemplate; @@ -25,13 +25,14 @@ public ApiTokenJdbcService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } - @Transactional(readOnly = true) public Optional findByToken(String token) { String sql = """ - SELECT at.id, at.token, at.name, at.created_at, at.last_used_at, - u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version + SELECT at.id, at.token, at.name, at.device_id, at.created_at, at.last_used_at, + u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version, + d.id as device_id, d.name as device_name, d.default_device as default_device, d.enabled as device_enabled, d.color as device_color, d.show_on_map as device_show_on_map, d.show_avatar_on_map as device_show_avatar_on_map, d.version as device_version, d.created_at as device_created_at, d.updated_at as device_updated_at, d.version as device_version FROM api_tokens at JOIN users u ON at.user_id = u.id + LEFT JOIN devices d ON at.device_id = d.id WHERE at.token = ? """; try { @@ -42,26 +43,28 @@ public Optional findByToken(String token) { } } - @Transactional(readOnly = true) public List findByUser(User user) { String sql = """ - SELECT at.id, at.token, at.name, at.created_at, at.last_used_at, - u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version + SELECT at.id, at.token, at.name, at.device_id, at.created_at, at.last_used_at, + u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version, + d.id as device_id, d.name as device_name, d.default_device as default_device, d.enabled as device_enabled, d.color as device_color, d.show_on_map as device_show_on_map, d.show_avatar_on_map as device_show_avatar_on_map, d.version as device_version, d.created_at as device_created_at, d.updated_at as device_updated_at, d.version as device_version FROM api_tokens at JOIN users u ON at.user_id = u.id + LEFT JOIN devices d ON at.device_id = d.id WHERE at.user_id = ? ORDER BY at.created_at DESC """; return jdbcTemplate.query(sql, this::mapRowToApiToken, user.getId()); } - @Transactional(readOnly = true) public Optional findById(Long id) { String sql = """ - SELECT at.id, at.token, at.name, at.created_at, at.last_used_at, - u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version + SELECT at.id, at.token, at.name, at.device_id, at.created_at, at.last_used_at, + u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version, + d.id as device_id, d.name as device_name, d.default_device as default_device, d.enabled as device_enabled, d.color as device_color, d.show_on_map as device_show_on_map, d.show_avatar_on_map as device_show_avatar_on_map, d.version as device_version, d.created_at as device_created_at, d.updated_at as device_updated_at, d.version as device_version FROM api_tokens at JOIN users u ON at.user_id = u.id + LEFT JOIN devices d ON at.device_id = d.id WHERE at.id = ? """; try { @@ -81,27 +84,29 @@ public ApiToken save(ApiToken apiToken) { } private ApiToken insert(ApiToken apiToken) { - String sql = "INSERT INTO api_tokens (token, user_id, name, created_at, last_used_at) VALUES (?, ?, ?, ?, ?) RETURNING id"; + String sql = "INSERT INTO api_tokens (token, user_id, device_id, name, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id"; Long id = jdbcTemplate.queryForObject(sql, Long.class, apiToken.getToken(), apiToken.getUser().getId(), + apiToken.getDevice() != null ? apiToken.getDevice().id() : null, apiToken.getName(), Timestamp.from(apiToken.getCreatedAt()), apiToken.getLastUsedAt() != null ? Timestamp.from(apiToken.getLastUsedAt()) : null ); - return new ApiToken(id, apiToken.getToken(), apiToken.getUser(), apiToken.getName(), + return new ApiToken(id, apiToken.getToken(), apiToken.getUser(), apiToken.getDevice(), apiToken.getName(), apiToken.getCreatedAt(), apiToken.getLastUsedAt()); } private ApiToken update(ApiToken apiToken) { - String sql = "UPDATE api_tokens SET token = ?, name = ?, last_used_at = ? WHERE id = ?"; + String sql = "UPDATE api_tokens SET token = ?, name = ?, last_used_at = ?, device_id = ? WHERE id = ?"; int rowsAffected = jdbcTemplate.update(sql, apiToken.getToken(), apiToken.getName(), apiToken.getLastUsedAt() != null ? Timestamp.from(apiToken.getLastUsedAt()) : null, + apiToken.getDevice() != null ? apiToken.getDevice().id() : null, apiToken.getId() ); @@ -124,13 +129,6 @@ public void delete(ApiToken apiToken) { deleteById(apiToken.getId()); } - @Transactional(readOnly = true) - public boolean existsById(Long id) { - String sql = "SELECT COUNT(*) FROM api_tokens WHERE id = ?"; - Integer count = jdbcTemplate.queryForObject(sql, Integer.class, id); - return count != null && count > 0; - } - @Transactional(readOnly = true) public long count() { String sql = "SELECT COUNT(*) FROM api_tokens"; @@ -150,10 +148,27 @@ private ApiToken mapRowToApiToken(ResultSet rs, int rowNum) throws SQLException rs.getLong("user_version") ); + + Device device = null; + if (rs.getObject("device_id") != null) { + device = new Device( + rs.getLong("device_id"), + rs.getString("device_name"), + rs.getBoolean("device_enabled"), + rs.getBoolean("device_show_on_map"), + rs.getBoolean("device_show_avatar_on_map"), + rs.getString("device_color"), + rs.getBoolean("default_device"), + rs.getTimestamp("device_created_at").toInstant(), + rs.getTimestamp("device_updated_at").toInstant(), + rs.getLong("device_version")); + } + return new ApiToken( rs.getLong("id"), rs.getString("token"), user, + device, rs.getString("name"), rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("last_used_at") != null ? rs.getTimestamp("last_used_at").toInstant() : null @@ -163,13 +178,22 @@ private ApiToken mapRowToApiToken(ResultSet rs, int rowNum) throws SQLException private ApiTokenUsage mapRowToApiUsage(ResultSet rs, int rowNum) throws SQLException { return new ApiTokenUsage(rs.getString("token"), rs.getString("name"), + rs.getString("device_name"), rs.getTimestamp("at").toInstant(), rs.getString("endpoint"), rs.getString("ip")); } public List getUsages(User user, int maxRows) { - return this.jdbcTemplate.query("SELECT t.token, t.name, au.at, au.endpoint, au.ip FROM api_tokens t RIGHT JOIN api_token_usages au on t.id = au.token_id WHERE t.user_id = ? ORDER BY au.at DESC LIMIT ?", this::mapRowToApiUsage, user.getId(), maxRows); + return this.jdbcTemplate.query(""" + SELECT t.token, t.name, t.device_id, au.at, au.endpoint, au.ip, d.name as device_name + FROM api_token_usages au + LEFT JOIN api_tokens t ON t.id = au.token_id + LEFT JOIN devices d ON t.device_id = d.id + WHERE t.user_id = ? + ORDER BY au.at DESC LIMIT ?; + """, + this::mapRowToApiUsage, user.getId(), maxRows); } public void trackUsage(String token, String requestPath, String remoteIp) { diff --git a/src/main/java/com/dedicatedcode/reitti/repository/DeviceJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/DeviceJdbcService.java new file mode 100644 index 000000000..6687c1221 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/DeviceJdbcService.java @@ -0,0 +1,168 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +public class DeviceJdbcService { + + private final JdbcTemplate jdbcTemplate; + + private final RowMapper deviceRowMapper = (rs, rowNum) -> new Device( + rs.getLong("id"), + rs.getString("name"), + rs.getBoolean("enabled"), + rs.getBoolean("show_on_map"), + rs.getBoolean("show_avatar_on_map"), + rs.getString("color"), + rs.getBoolean("default_device"), + rs.getTimestamp("created_at").toInstant(), + rs.getTimestamp("updated_at").toInstant(), + rs.getLong("version") + ); + + public DeviceJdbcService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @CacheEvict(value = "devices", allEntries = true) + public Device save(Device device, User user) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO devices (user_id, name, color, enabled, show_on_map, show_avatar_on_map, default_device, created_at, updated_at, version) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, user.getId()); + ps.setString(2, device.name()); + ps.setString(3, device.color()); + ps.setBoolean(4, device.enabled()); + ps.setBoolean(5, device.showOnMap()); + ps.setBoolean(6, device.showAvatarOnMap()); + ps.setBoolean(7, device.defaultDevice()); + ps.setTimestamp(8, Timestamp.from(device.createdAt())); + ps.setTimestamp(9, Timestamp.from(device.updatedAt())); + ps.setLong(10, 1L); + return ps; + }, keyHolder); + + Number key = keyHolder.getKey(); + return new Device( + key.longValue(), + device.name(), + device.enabled(), + device.showOnMap(), + device.showAvatarOnMap(), + device.color(), + device.defaultDevice(), + device.createdAt(), + device.updatedAt(), + 1L + ); + } + + @CacheEvict(value = "devices", allEntries = true) + public Device update(Device device, User user) { + int updated = jdbcTemplate.update( + "UPDATE devices SET name = ?, color = ?, default_device = ?, enabled = ?, show_on_map = ?, show_avatar_on_map = ?, updated_at = ?, version = version + 1 " + + "WHERE id = ? AND user_id = ?", + device.name(), + device.color(), + device.defaultDevice(), + device.enabled(), + device.showOnMap(), + device.showAvatarOnMap(), + Timestamp.from(Instant.now()), + device.id(), + user.getId() + ); + + if (updated == 0) { + throw new IllegalArgumentException("Device not found or not owned by user"); + } + + return new Device( + device.id(), + device.name(), + device.enabled(), + device.showOnMap(), + device.showAvatarOnMap(), + device.color(), + device.defaultDevice(), + device.createdAt(), + Instant.now(), + device.version() + 1 + ); + } + + @CacheEvict(value = "devices", allEntries = true) + public void delete(Device device, User user) { + jdbcTemplate.update( + "DELETE FROM devices WHERE id = ? AND user_id = ?", + device.id(), + user.getId() + ); + } + + public List getAll(User user) { + return jdbcTemplate.query( + "SELECT * FROM devices WHERE user_id = ? ORDER BY default_device DESC, name", + deviceRowMapper, + user.getId() + ); + } + + public List getAllEnabled(User user) { + return jdbcTemplate.query( + "SELECT * FROM devices WHERE user_id = ? AND enabled = TRUE ORDER BY default_device DESC, name", + deviceRowMapper, + user.getId() + ); + } + + @Cacheable(value = "devices", key = "#token") + public Optional findByApiToken(String token) { + List results = jdbcTemplate.query( + "SELECT d.* FROM devices d " + + "JOIN api_tokens t ON t.device_id = d.id " + + "WHERE t.token = ?", + deviceRowMapper, + token + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + public Optional find(User user, Long deviceId) { + List results = jdbcTemplate.query( + "SELECT d.* FROM devices d " + + "WHERE d.user_id = ? AND d.id = ?", + deviceRowMapper, + user.getId(), deviceId); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + public void deleteForUser(User user) { + this.jdbcTemplate.update("DELETE FROM devices WHERE user_id = ?", user.getId()); + } + + public Device getDefaultDevice(User user) { + List defaultDevices = this.jdbcTemplate.query("SELECT * FROM devices WHERE user_id = ? AND default_device = TRUE", deviceRowMapper, user.getId()); + return defaultDevices.stream().findFirst().orElseThrow(() -> new IllegalStateException("No default device found for user " + user.getId() + ". This should never happen.")); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/GeocodeServiceJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/GeocodeServiceJdbcService.java index 7105d6348..c16497b5b 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/GeocodeServiceJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/GeocodeServiceJdbcService.java @@ -65,35 +65,35 @@ public GeocodeService save(GeocodeService geocodeService) { String sql = "INSERT INTO geocode_services (name, url, enabled, error_count, last_used, last_error, type, priority, additional_params, version) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"; Long id = jdbcTemplate.queryForObject(sql, Long.class, - geocodeService.getName(), - geocodeService.getUrl(), - geocodeService.isEnabled(), - geocodeService.getErrorCount(), - geocodeService.getLastUsed() != null ? java.sql.Timestamp.from(geocodeService.getLastUsed()) : null, - geocodeService.getLastError() != null ? java.sql.Timestamp.from(geocodeService.getLastError()) : null, - geocodeService.getType().name(), - geocodeService.getPriority(), - geocodeService.getAdditionalParameters() != null ? this.objectMapper.writeValueAsString(geocodeService.getAdditionalParameters()) : null, - geocodeService.getVersion() + geocodeService.getName(), + geocodeService.getUrl(), + geocodeService.isEnabled(), + geocodeService.getErrorCount(), + geocodeService.getLastUsed() != null ? java.sql.Timestamp.from(geocodeService.getLastUsed()) : null, + geocodeService.getLastError() != null ? java.sql.Timestamp.from(geocodeService.getLastError()) : null, + geocodeService.getType().name(), + geocodeService.getPriority(), + geocodeService.getAdditionalParameters() != null ? this.objectMapper.writeValueAsString(geocodeService.getAdditionalParameters()) : null, + geocodeService.getVersion() ); - return geocodeService.withId(id); - } else { - String sql = "UPDATE geocode_services SET name = ?, url = ?, enabled = ?, error_count = ?, last_used = ?, last_error = ?, type = ?, priority = ?, additional_params = ?, version = ? WHERE id = ?"; - jdbcTemplate.update(sql, - geocodeService.getName(), - geocodeService.getUrl(), - geocodeService.isEnabled(), - geocodeService.getErrorCount(), - geocodeService.getLastUsed() != null ? java.sql.Timestamp.from(geocodeService.getLastUsed()) : null, - geocodeService.getLastError() != null ? java.sql.Timestamp.from(geocodeService.getLastError()) : null, - geocodeService.getType().name(), - geocodeService.getPriority(), - geocodeService.getAdditionalParameters() != null ? this.objectMapper.writeValueAsString(geocodeService.getAdditionalParameters()) : null, - geocodeService.getVersion(), - geocodeService.getId() - ); - return geocodeService; + return geocodeService.withId(id); + } else { + String sql = "UPDATE geocode_services SET name = ?, url = ?, enabled = ?, error_count = ?, last_used = ?, last_error = ?, type = ?, priority = ?, additional_params = ?, version = ? WHERE id = ?"; + jdbcTemplate.update(sql, + geocodeService.getName(), + geocodeService.getUrl(), + geocodeService.isEnabled(), + geocodeService.getErrorCount(), + geocodeService.getLastUsed() != null ? java.sql.Timestamp.from(geocodeService.getLastUsed()) : null, + geocodeService.getLastError() != null ? java.sql.Timestamp.from(geocodeService.getLastError()) : null, + geocodeService.getType().name(), + geocodeService.getPriority(), + geocodeService.getAdditionalParameters() != null ? this.objectMapper.writeValueAsString(geocodeService.getAdditionalParameters()) : null, + geocodeService.getVersion(), + geocodeService.getId() + ); + return geocodeService; } } catch (JsonProcessingException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/dedicatedcode/reitti/repository/JobMetadataRepository.java b/src/main/java/com/dedicatedcode/reitti/repository/JobMetadataRepository.java new file mode 100644 index 000000000..8b0a208bc --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/JobMetadataRepository.java @@ -0,0 +1,252 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.jobs.JobState; +import com.dedicatedcode.reitti.service.jobs.JobType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class JobMetadataRepository { + private final JdbcTemplate jdbcTemplate; + private final RowMapper jobMetadataRowMapper = (rs, ignored) -> { + String parentJobIdStr = rs.getString("parent_job_id"); + return new JobMetadata( + UUID.fromString(rs.getString("id")), + rs.getString("task_id"), + parentJobIdStr != null ? UUID.fromString(parentJobIdStr) : null, + rs.getLong("user_id"), + JobType.valueOf(rs.getString("type")), + rs.getString("friendly_name"), + JobState.valueOf(rs.getString("status")), + toInstant(rs.getTimestamp("enqueued_at")), + toInstant(rs.getTimestamp("scheduled_at")), + toInstant(rs.getTimestamp("processing_at")), + toInstant(rs.getTimestamp("finished_at")), + rs.getString("progress_message"), + (Long) rs.getObject("current_progress"), + (Long) rs.getObject("max_progress")); + }; + + public JobMetadataRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void insert(UUID jobId, User user, String taskId, JobType jobType, String friendlyName, JobState initialState, Instant enqueuedAt, Instant scheduledAt, UUID parentId) { + jdbcTemplate.update( + "INSERT INTO job_meta_data (id, user_id, task_id, type, friendly_name, status, enqueued_at, scheduled_at, parent_job_id, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())", + jobId, + user.getId(), + taskId, + jobType.name(), + friendlyName, + initialState.name(), + toTimestamp(enqueuedAt), + toTimestamp(scheduledAt), + parentId); + } + + private Timestamp toTimestamp(Instant instant) { + return instant == null ? null : Timestamp.from(instant); + } + + private Instant toInstant(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toInstant(); + } + + public void updateProgress(UUID jobId, long current, long max, String message) { + this.jdbcTemplate.update("UPDATE job_meta_data SET current_progress = ?, max_progress = ?, progress_message = ? WHERE id = ?", current, max, message, jobId); + } + + public void updateState(UUID jobId, JobState newState, Instant stateTimestamp) { + String column = switch (newState) { + case RUNNING -> "processing_at"; + case FAILED, COMPLETED -> "finished_at"; + default -> null; + }; + + if (column != null) { + jdbcTemplate.update( + "UPDATE job_meta_data SET status = ?, " + column + " = ?, updated_at = NOW() WHERE id = ?", + newState.name(), + toTimestamp(stateTimestamp), + jobId + ); + } else { + jdbcTemplate.update( + "UPDATE job_meta_data SET status = ?, updated_at = NOW() WHERE id = ?", + newState.name(), + jobId + ); + } + } + + public Optional getState(UUID jobId) { + String state = jdbcTemplate.queryForObject( + "SELECT status FROM job_meta_data WHERE id = ?", + String.class, + jobId + ); + if (state != null) { + return Optional.of(JobState.valueOf(state)); + } else { + return Optional.empty(); + } + } + + public List findByStates(List states) { + if (states.isEmpty()) { + return List.of(); + } + String inClause = String.join(",", Collections.nCopies(states.size(), "?")); + String sql = "SELECT id, user_id, task_id, type, friendly_name, status, enqueued_at, scheduled_at, processing_at, finished_at, parent_job_id, current_progress, max_progress, progress_message " + + "FROM job_meta_data WHERE status IN (" + inClause + ") ORDER BY created_at DESC"; + return jdbcTemplate.query(sql, jobMetadataRowMapper, states.stream().map(Enum::name).toArray()); + } + + public List findByParentJobId(UUID parentId) { + String sql = "SELECT id, user_id, task_id, type, friendly_name, status, enqueued_at, scheduled_at, processing_at, finished_at, parent_job_id, current_progress, max_progress, progress_message " + + "FROM job_meta_data WHERE parent_job_id = ?"; + return jdbcTemplate.query(sql, jobMetadataRowMapper, parentId); + } + + public Optional findById(UUID jobId) { + return Optional.ofNullable(this.jdbcTemplate.queryForObject("SELECT * FROM job_meta_data WHERE id = ?", jobMetadataRowMapper, jobId)); + } + + public void updateParentJobState(UUID parentJobId, JobState newState) { + Optional parent = findById(parentJobId); + if (parent.isEmpty()) return; + + JobState currentState = parent.get().getState(); + + if (newState == JobState.RUNNING) { + // Only update if currently awaiting + if (currentState == JobState.AWAITING) { + updateState(parentJobId, JobState.RUNNING, Instant.now()); + } + } else if (newState == JobState.COMPLETED || newState == JobState.FAILED) { + // Check all children before completing + List childJobs = findByParentJobId(parentJobId); + + boolean allComplete = childJobs.stream() + .allMatch(j -> j.getState() == JobState.COMPLETED || j.getState() == JobState.FAILED); + + if (allComplete) { + boolean anyFailed = childJobs.stream() + .anyMatch(j -> j.getState() == JobState.FAILED); + + JobState finalState = anyFailed ? JobState.FAILED : JobState.COMPLETED; + updateState(parentJobId, finalState, Instant.now()); + } + } + } + + public void delete(UUID jobId) { + this.jdbcTemplate.update("DELETE FROM job_meta_data WHERE id = ?", jobId); + } + + public int deleteOlderThan(Instant cutoff) { + String sql = "DELETE FROM job_meta_data WHERE enqueued_at < ?"; + return jdbcTemplate.update(sql, Timestamp.from(cutoff)); + } + + public static class JobMetadata { + private final UUID id; + private final String taskId; + private final UUID parentJobId; + private final Long userId; + private final JobType jobType; + private final String friendlyName; + private final JobState state; + private final Instant enqueuedAt; + private final Instant scheduledAt; + private final Instant processingAt; + private final Instant finishedAt; + private final String progressMessage; + private final Long currentProgress; + private final Long maxProgress; + + public JobMetadata(UUID id, String taskId, UUID parentJobId, Long userId, JobType jobType, String friendlyName, JobState state, Instant enqueuedAt, Instant scheduledAt, Instant processingAt, Instant finishedAt, String progressMessage, Long currentProgress, Long maxProgress) { + this.id = id; + this.taskId = taskId; + this.parentJobId = parentJobId; + this.userId = userId; + this.jobType = jobType; + this.friendlyName = friendlyName; + this.state = state; + this.enqueuedAt = enqueuedAt; + this.scheduledAt = scheduledAt; + this.processingAt = processingAt; + this.finishedAt = finishedAt; + this.progressMessage = progressMessage; + this.currentProgress = currentProgress; + this.maxProgress = maxProgress; + } + + public UUID getId() { + return id; + } + + public String getTaskId() { + return taskId; + } + + public UUID getParentJobId() { + return parentJobId; + } + + public Long getUserId() { + return userId; + } + + public JobType getJobType() { + return jobType; + } + + public String getFriendlyName() { + return friendlyName; + } + + public JobState getState() { + return state; + } + + public Instant getEnqueuedAt() { + return enqueuedAt; + } + + public Instant getScheduledAt() { + return scheduledAt; + } + + public Instant getProcessingAt() { + return processingAt; + } + + public Instant getFinishedAt() { + return finishedAt; + } + + public String getProgressMessage() { + return progressMessage; + } + + public Long getCurrentProgress() { + return currentProgress; + } + + public Long getMaxProgress() { + return maxProgress; + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/MetadataOverrideJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/MetadataOverrideJdbcService.java new file mode 100644 index 000000000..9e350b360 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/MetadataOverrideJdbcService.java @@ -0,0 +1,124 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.model.metadata.MemoryMetadata; +import com.dedicatedcode.reitti.model.security.User; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Repository +public class MetadataOverrideJdbcService { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final RowMapper metadataRowMapper; + + public MetadataOverrideJdbcService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.metadataRowMapper = (rs, _) -> { + try { + MemoryMetadata metadata = new MemoryMetadata(rs.getTimestamp("start_time").toInstant(), + rs.getTimestamp("end_time").toInstant()); + Map properties = objectMapper.readValue(rs.getString("metadata"), new TypeReference<>() {}); + metadata.setProperties(properties); + return metadata; + } catch (Exception e) { + throw new RuntimeException("Failed to decode JSONB metadata context", e); + } + }; + } + + private String toRangeLiteral(Instant start, Instant end) { + return String.format("[%s,%s)", start.toString(), end.toString()); + } + + public Optional findBestOverlappingOverride(User user, Instant newStart, Instant newEnd) { + String rangeParam = toRangeLiteral(newStart, newEnd); + + String sql = """ + SELECT lower(time_range) as start_time, upper(time_range) as end_time, metadata + FROM location_metadata + WHERE time_range && ?::tstzrange + AND user_id = ? + ORDER BY upper(time_range * ?::tstzrange) - lower(time_range * ?::tstzrange) DESC + LIMIT 1 + """; + + try { + MemoryMetadata match = jdbcTemplate.queryForObject( + sql, + metadataRowMapper, + rangeParam, + user.getId(), + rangeParam, + rangeParam + ); + return Optional.ofNullable(match); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + public void insertOverride(User user, String contextType, MemoryMetadata metadata) { + String sql = """ + INSERT INTO location_metadata (user_id, context_type, time_range, metadata) + VALUES (?, ?, ?::tstzrange, ?::jsonb) + """; + try { + String rangeLiteral = toRangeLiteral(metadata.getStartTime(), metadata.getEndTime()); + String jsonPayload = objectMapper.writeValueAsString(metadata.getProperties()); + jdbcTemplate.update(sql, user.getId(), contextType, rangeLiteral, jsonPayload); + } catch (Exception e) { + throw new RuntimeException("Serialization mapping failed", e); + } + } + + public void updateOverridePayload(User user, MemoryMetadata metadata) { + String sql = """ + UPDATE location_metadata + SET metadata = ?::jsonb + WHERE time_range = ?::tstzrange + AND user_id = ? + """; + try { + String rangeLiteral = toRangeLiteral(metadata.getStartTime(), metadata.getEndTime()); + String jsonPayload = objectMapper.writeValueAsString(metadata.getProperties()); + jdbcTemplate.update(sql, jsonPayload, rangeLiteral, user.getId()); + } catch (Exception e) { + throw new RuntimeException("Serialization mapping failed", e); + } + } + + public List findDistinctSuggestions(User user, String field, String query) { + if ("reason".equals(field)) { + String sql = """ + SELECT DISTINCT metadata->>'reason' AS value + FROM location_metadata + WHERE user_id = ? + AND metadata->>'reason' IS NOT NULL + AND metadata->>'reason' ILIKE ? + ORDER BY value + """; + return jdbcTemplate.queryForList(sql, String.class, user.getId(), query + "%"); + } else if ("tags".equals(field)) { + String sql = """ + SELECT DISTINCT tag + FROM location_metadata, jsonb_array_elements_text(metadata->'tags') AS tag + WHERE user_id = ? + AND tag ILIKE ? + ORDER BY tag + """; + return jdbcTemplate.queryForList(sql, String.class, user.getId(), query + "%"); + } else { + return List.of(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/repository/MqttIntegrationJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/MqttIntegrationJdbcService.java index ee9f69ef1..3c8c9d100 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/MqttIntegrationJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/MqttIntegrationJdbcService.java @@ -27,7 +27,7 @@ public MqttIntegrationJdbcService(JdbcTemplate jdbcTemplate) { public Optional findByUser(User user) { String sql = """ - SELECT id, user_id, host, port, use_tls, identifier, topic, username, password, + SELECT id, user_id, host, port, use_tls, identifier, topic, username, password, device_id, payload_type, enabled, created_at, updated_at, last_used, version FROM mqtt_integrations WHERE user_id = ? @@ -48,8 +48,8 @@ public MqttIntegration save(User user, MqttIntegration integration) { private MqttIntegration create(User user, MqttIntegration integration) { String sql = """ INSERT INTO mqtt_integrations (user_id, host, port, use_tls, identifier, topic, username, password, - payload_type, enabled, created_at, version) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + payload_type, enabled, created_at, device_id, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; KeyHolder keyHolder = new GeneratedKeyHolder(); @@ -68,7 +68,8 @@ INSERT INTO mqtt_integrations (user_id, host, port, use_tls, identifier, topic, ps.setString(9, integration.getPayloadType().name()); ps.setBoolean(10, integration.isEnabled()); ps.setTimestamp(11, Timestamp.from(now)); - ps.setLong(12, 1L); + ps.setLong(12, integration.getDeviceId()); + ps.setLong(13, 1L); return ps; }, keyHolder); @@ -84,6 +85,7 @@ INSERT INTO mqtt_integrations (user_id, host, port, use_tls, identifier, topic, integration.getPassword(), integration.getPayloadType(), integration.isEnabled(), + integration.getDeviceId(), now, null, null, @@ -94,7 +96,7 @@ INSERT INTO mqtt_integrations (user_id, host, port, use_tls, identifier, topic, private MqttIntegration update(MqttIntegration integration) { String sql = """ UPDATE mqtt_integrations - SET host = ?, port = ?, use_tls = ?, identifier = ?, topic = ?, username = ?, password = ?, + SET host = ?, port = ?, use_tls = ?, identifier = ?, topic = ?, username = ?, password = ?, device_id = ?, payload_type = ?, enabled = ?, updated_at = ?, version = version + 1 WHERE id = ? AND version = ? """; @@ -108,6 +110,7 @@ private MqttIntegration update(MqttIntegration integration) { integration.getTopic(), integration.getUsername(), integration.getPassword(), + integration.getDeviceId(), integration.getPayloadType().name(), integration.isEnabled(), Timestamp.from(now), @@ -130,6 +133,7 @@ private MqttIntegration update(MqttIntegration integration) { integration.getPassword(), integration.getPayloadType(), integration.isEnabled(), + integration.getDeviceId(), integration.getCreatedAt(), now, integration.getLastUsed(), @@ -158,6 +162,7 @@ public MqttIntegration mapRow(ResultSet rs, int rowNum) throws SQLException { rs.getString("password"), PayloadType.valueOf(rs.getString("payload_type")), rs.getBoolean("enabled"), + rs.getLong("device_id"), rs.getTimestamp("created_at").toInstant(), updatedAt != null ? updatedAt.toInstant() : null, lastUsed != null ? lastUsed.toInstant() : null, diff --git a/src/main/java/com/dedicatedcode/reitti/repository/OwnTracksRecorderIntegrationJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/OwnTracksRecorderIntegrationJdbcService.java index 121d886f1..82380785d 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/OwnTracksRecorderIntegrationJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/OwnTracksRecorderIntegrationJdbcService.java @@ -33,13 +33,13 @@ public OwnTracksRecorderIntegrationJdbcService(JdbcTemplate jdbcTemplate) { rs.getString("device_id"), rs.getString("auth_username"), rs.getString("auth_password"), - rs.getBoolean("enabled"), - lastSuccessfulFetch, rs.getLong("version")); + rs.getLong("reitti_device_id"), + rs.getBoolean("enabled"), lastSuccessfulFetch, rs.getLong("version")); }; public Optional findByUser(User user) { try { - String sql = "SELECT id, base_url, username, device_id, enabled, auth_username, auth_password, last_successful_fetch, user_id, version FROM owntracks_recorder_integration WHERE user_id = ?"; + String sql = "SELECT id, base_url, username, device_id, reitti_device_id, enabled, auth_username, auth_password, last_successful_fetch, user_id, version FROM owntracks_recorder_integration WHERE user_id = ?"; OwnTracksRecorderIntegration integration = jdbcTemplate.queryForObject(sql, rowMapper, user.getId()); return Optional.ofNullable(integration); } catch (EmptyResultDataAccessException e) { @@ -48,7 +48,7 @@ public Optional findByUser(User user) { } public OwnTracksRecorderIntegration save(User user, OwnTracksRecorderIntegration integration) { - String sql = "INSERT INTO owntracks_recorder_integration (base_url, username, device_id, enabled, auth_username, auth_password, last_successful_fetch, user_id, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + String sql = "INSERT INTO owntracks_recorder_integration (base_url, username, device_id, reitti_device_id, enabled, auth_username, auth_password, last_successful_fetch, user_id, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { @@ -56,16 +56,17 @@ public OwnTracksRecorderIntegration save(User user, OwnTracksRecorderIntegration ps.setString(1, integration.getBaseUrl()); ps.setString(2, integration.getUsername()); ps.setString(3, integration.getDeviceId()); - ps.setBoolean(4, integration.isEnabled()); - ps.setString(5, integration.getAuthUsername()); - ps.setString(6, integration.getAuthPassword()); + ps.setLong(4, integration.getReittiDeviceId()); + ps.setBoolean(5, integration.isEnabled()); + ps.setString(6, integration.getAuthUsername()); + ps.setString(7, integration.getAuthPassword()); if (integration.getLastSuccessfulFetch() != null) { - ps.setTimestamp(7, java.sql.Timestamp.from(integration.getLastSuccessfulFetch())); + ps.setTimestamp(8, java.sql.Timestamp.from(integration.getLastSuccessfulFetch())); } else { - ps.setTimestamp(7, null); + ps.setTimestamp(8, null); } - ps.setLong(8, user.getId()); - ps.setLong(9, 1L); // Initial version + ps.setLong(9, user.getId()); + ps.setLong(10, 1L); // Initial version return ps; }, keyHolder); @@ -74,12 +75,13 @@ public OwnTracksRecorderIntegration save(User user, OwnTracksRecorderIntegration } public OwnTracksRecorderIntegration update(OwnTracksRecorderIntegration integration) { - String sql = "UPDATE owntracks_recorder_integration SET base_url = ?, username = ?, device_id = ?, enabled = ?, auth_username = ?, auth_password = ?, last_successful_fetch = ?, version = version + 1 WHERE id = ? AND version = ?"; + String sql = "UPDATE owntracks_recorder_integration SET base_url = ?, username = ?, device_id = ?, reitti_device_id = ?, enabled = ?, auth_username = ?, auth_password = ?, last_successful_fetch = ?, version = version + 1 WHERE id = ? AND version = ?"; int rowsAffected = jdbcTemplate.update(sql, integration.getBaseUrl(), integration.getUsername(), integration.getDeviceId(), + integration.getReittiDeviceId(), integration.isEnabled(), integration.getAuthUsername(), integration.getAuthPassword(), diff --git a/src/main/java/com/dedicatedcode/reitti/repository/PreviewProcessedVisitJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/PreviewProcessedVisitJdbcService.java index ba37da3e2..658f2868e 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/PreviewProcessedVisitJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/PreviewProcessedVisitJdbcService.java @@ -3,6 +3,9 @@ import com.dedicatedcode.reitti.model.geo.ProcessedVisit; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -13,20 +16,19 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; +import java.util.*; @Service @Transactional public class PreviewProcessedVisitJdbcService { private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; private final PreviewSignificantPlaceJdbcService significantPlaceJdbcService; - public PreviewProcessedVisitJdbcService(JdbcTemplate jdbcTemplate, PreviewSignificantPlaceJdbcService significantPlaceJdbcService) { + public PreviewProcessedVisitJdbcService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper, PreviewSignificantPlaceJdbcService significantPlaceJdbcService) { this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; this.significantPlaceJdbcService = significantPlaceJdbcService; } @@ -35,15 +37,21 @@ public PreviewProcessedVisitJdbcService(JdbcTemplate jdbcTemplate, PreviewSignif public ProcessedVisit mapRow(ResultSet rs, int rowNum) throws SQLException { SignificantPlace place = significantPlaceJdbcService.findById(rs.getLong("place_id")).orElseThrow(); Long processedVisitId = rs.getLong("id"); + try { + Map metadata = objectMapper.readValue(rs.getString("metadata"),new TypeReference<>() {}); + return new ProcessedVisit( + processedVisitId, + place, + rs.getTimestamp("start_time").toInstant(), + rs.getTimestamp("end_time").toInstant(), + rs.getLong("duration_seconds"), + metadata, + rs.getLong("version") + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } - return new ProcessedVisit( - processedVisitId, - place, - rs.getTimestamp("start_time").toInstant(), - rs.getTimestamp("end_time").toInstant(), - rs.getLong("duration_seconds"), - rs.getLong("version") - ); } }; diff --git a/src/main/java/com/dedicatedcode/reitti/repository/PreviewRawLocationPointJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/PreviewRawLocationPointJdbcService.java index 55b1677a7..793099d05 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/PreviewRawLocationPointJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/PreviewRawLocationPointJdbcService.java @@ -1,6 +1,5 @@ package com.dedicatedcode.reitti.repository; -import com.dedicatedcode.reitti.model.ClusteredPoint; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; import com.dedicatedcode.reitti.model.security.User; import org.springframework.jdbc.core.JdbcTemplate; @@ -19,29 +18,25 @@ public class PreviewRawLocationPointJdbcService { private final JdbcTemplate jdbcTemplate; private final RowMapper rawLocationPointRowMapper; - private final PointReaderWriter pointReaderWriter; public PreviewRawLocationPointJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter) { this.jdbcTemplate = jdbcTemplate; this.rawLocationPointRowMapper = (rs, _) -> new RawLocationPoint( rs.getLong("id"), + null, rs.getTimestamp("timestamp").toInstant(), pointReaderWriter.read(rs.getString("geom")), rs.getDouble("accuracy_meters"), rs.getObject("elevation_meters", Double.class), rs.getBoolean("processed"), rs.getBoolean("synthetic"), - rs.getBoolean("ignored"), - false, rs.getLong("version") ); - - this.pointReaderWriter = pointReaderWriter; } public List findByUserAndTimestampBetweenOrderByTimestampAsc( User user, String previewId, Instant startTime, Instant endTime) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.version " + + String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM preview_raw_location_points rlp " + "WHERE rlp.user_id = ? AND rlp.timestamp BETWEEN ? AND ? AND preview_id = ? " + "ORDER BY rlp.timestamp"; @@ -50,7 +45,7 @@ public List findByUserAndTimestampBetweenOrderByTimestampAsc( } public List findByUserAndProcessedIsFalseOrderByTimestampWithLimit(User user, String previewId, int limit, int offset) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.version " + + String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM preview_raw_location_points rlp " + "WHERE rlp.user_id = ? AND rlp.processed = false AND preview_id = ? " + "ORDER BY rlp.timestamp " + @@ -58,35 +53,6 @@ public List findByUserAndProcessedIsFalseOrderByTimestampWithL return jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), previewId, limit, offset); } - public List findClusteredPointsInTimeRangeForUser( - User user, String previewId, Instant startTime, Instant endTime, int minimumPoints, double distanceInMeters) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.version , " + - "ST_ClusterDBSCAN(rlp.geom, ?, ?) over () AS cluster_id " + - "FROM preview_raw_location_points rlp " + - "WHERE rlp.user_id = ? AND rlp.timestamp BETWEEN ? AND ? AND preview_id = ?"; - - return jdbcTemplate.query(sql, (rs, _) -> { - - RawLocationPoint point = new RawLocationPoint( - rs.getLong("id"), - rs.getTimestamp("timestamp").toInstant(), - this.pointReaderWriter.read(rs.getString("geom")), - rs.getDouble("accuracy_meters"), - rs.getObject("elevation_meters", Double.class), - rs.getBoolean("processed"), - rs.getBoolean("synthetic"), - rs.getBoolean("ignored"), - false, - rs.getLong("version") - ); - - Integer clusterId = rs.getObject("cluster_id", Integer.class); - - return new ClusteredPoint(point, clusterId); - }, distanceInMeters, minimumPoints, user.getId(), - Timestamp.from(startTime), Timestamp.from(endTime), previewId); - } - public void bulkUpdateProcessedStatus(List points) { if (points.isEmpty()) { return; diff --git a/src/main/java/com/dedicatedcode/reitti/repository/PreviewTripJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/PreviewTripJdbcService.java index 8192bdea1..82641cb32 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/PreviewTripJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/PreviewTripJdbcService.java @@ -42,6 +42,7 @@ public Trip mapRow(ResultSet rs, int rowNum) throws SQLException { TransportMode.valueOf(rs.getString("transport_mode_inferred")), startVisit, endVisit, + null, rs.getLong("version") ); } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java index cb9dc51c4..aeb49037e 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java @@ -3,6 +3,10 @@ import com.dedicatedcode.reitti.model.geo.ProcessedVisit; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -14,10 +18,7 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; +import java.util.*; @Service @Transactional @@ -25,10 +26,12 @@ public class ProcessedVisitJdbcService { private final JdbcTemplate jdbcTemplate; private final SignificantPlaceJdbcService significantPlaceJdbcService; + private final ObjectMapper objectMapper; - public ProcessedVisitJdbcService(JdbcTemplate jdbcTemplate, SignificantPlaceJdbcService significantPlaceJdbcService) { + public ProcessedVisitJdbcService(JdbcTemplate jdbcTemplate, SignificantPlaceJdbcService significantPlaceJdbcService, ObjectMapper objectMapper) { this.jdbcTemplate = jdbcTemplate; this.significantPlaceJdbcService = significantPlaceJdbcService; + this.objectMapper = objectMapper; } private final RowMapper PROCESSED_VISIT_ROW_MAPPER = new RowMapper<>() { @@ -36,15 +39,23 @@ public ProcessedVisitJdbcService(JdbcTemplate jdbcTemplate, SignificantPlaceJdbc public ProcessedVisit mapRow(ResultSet rs, int rowNum) throws SQLException { SignificantPlace place = significantPlaceJdbcService.findById(rs.getLong("place_id")).orElseThrow(); Long processedVisitId = rs.getLong("id"); + try { + String metadataValue = rs.getString("metadata"); + Map metadata = metadataValue != null ? objectMapper.readValue(metadataValue, new TypeReference<>() {}) : null; + return new ProcessedVisit( + processedVisitId, + place, + rs.getTimestamp("start_time").toInstant(), + rs.getTimestamp("end_time").toInstant(), + rs.getLong("duration_seconds"), + metadata, + rs.getLong("version") + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + - return new ProcessedVisit( - processedVisitId, - place, - rs.getTimestamp("start_time").toInstant(), - rs.getTimestamp("end_time").toInstant(), - rs.getLong("duration_seconds"), - rs.getLong("version") - ); } }; @@ -126,25 +137,35 @@ public List findTopPlacesByStayTimeWithLimit(User user, Instant startT } public ProcessedVisit create(User user, ProcessedVisit visit) { - String sql = "INSERT INTO processed_visits (user_id, start_time, end_time, duration_seconds, place_id, version) " + - "VALUES (?, ?, ?, ?, ?, 1) RETURNING id"; + String sql = "INSERT INTO processed_visits (user_id, start_time, end_time, duration_seconds, place_id, metadata, version) " + + "VALUES (?, ?, ?, ?, ?, ?::jsonb, 1) RETURNING id"; Long id = jdbcTemplate.queryForObject(sql, Long.class, user.getId(), Timestamp.from(visit.getStartTime()), Timestamp.from(visit.getEndTime()), visit.getDurationSeconds(), - visit.getPlace() != null ? visit.getPlace().getId() : null + visit.getPlace() != null ? visit.getPlace().getId() : null, + asJson(visit.getMetadata()) ); return visit.withId(id).withVersion(1); } + private String asJson(Object value) { + try { + return this.objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + public ProcessedVisit update(ProcessedVisit visit) { - String sql = "UPDATE processed_visits SET start_time = ?, end_time = ?, duration_seconds = ?, place_id = ? WHERE id = ?"; + String sql = "UPDATE processed_visits SET start_time = ?, end_time = ?, duration_seconds = ?, place_id = ?, metadata = ?::jsonb WHERE id = ?"; jdbcTemplate.update(sql, Timestamp.from(visit.getStartTime()), Timestamp.from(visit.getEndTime()), visit.getDurationSeconds(), visit.getPlace().getId(), + asJson(visit.getMetadata()), visit.getId() ); return visit; @@ -181,10 +202,10 @@ public List bulkInsert(User user, List visitsToS List result = new ArrayList<>(); // 1. Build the multi-row INSERT statement structure - String valuePlaceholder = "(?, ?, ?, ?, ?)"; + String valuePlaceholder = "(?, ?, ?, ?, ?, ?::jsonb)"; String valuesPlaceholders = String.join(", ", Collections.nCopies(visitsToStore.size(), valuePlaceholder)); - String sql = "INSERT INTO processed_visits (user_id, place_id, start_time, end_time, duration_seconds)\n" + + String sql = "INSERT INTO processed_visits (user_id, place_id, start_time, end_time, duration_seconds, metadata)\n" + "VALUES " + valuesPlaceholders + " RETURNING id;"; List batchArgs = new ArrayList<>(); @@ -194,6 +215,7 @@ public List bulkInsert(User user, List visitsToS batchArgs.add(Timestamp.from(visit.getStartTime())); batchArgs.add(Timestamp.from(visit.getEndTime())); batchArgs.add(visit.getDurationSeconds()); + batchArgs.add(asJson(visit.getMetadata())); } List updateCounts = jdbcTemplate.query(sql, new ArgumentPreparedStatementSetter(batchArgs.toArray()), (resultSet, _) -> resultSet.getLong("id")); diff --git a/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java index fd7765e43..4a00b4f8e 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java @@ -4,10 +4,9 @@ import com.dedicatedcode.reitti.dto.MapMetadata; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.processing.TimeRange; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; @@ -35,14 +34,13 @@ public RawLocationPointJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter this.jdbcTemplate = jdbcTemplate; this.rawLocationPointRowMapper = (rs, _) -> new RawLocationPoint( rs.getLong("id"), + (Long) rs.getObject("source_point_id"), rs.getTimestamp("timestamp").toInstant(), pointReaderWriter.read(rs.getString("geom")), rs.getDouble("accuracy_meters"), rs.getObject("elevation_meters", Double.class), rs.getBoolean("processed"), rs.getBoolean("synthetic"), - rs.getBoolean("ignored"), - rs.getBoolean("invalid"), rs.getLong("version") ); @@ -53,95 +51,64 @@ public RawLocationPointJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter public List findByUserAndTimestampBetweenOrderByTimestampAsc( User user, Instant startTime, Instant endTime) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.invalid, rlp.ignored, rlp.version " + + String sql = "SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM raw_location_points rlp " + - "WHERE rlp.user_id = ? AND rlp.timestamp >= ? AND rlp.timestamp < ? AND rlp.invalid = false " + + "WHERE rlp.user_id = ? AND rlp.timestamp >= ? AND rlp.timestamp < ? " + "ORDER BY rlp.timestamp"; return jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), Timestamp.from(startTime), Timestamp.from(endTime)); } - public List findByUserAndTimestampBetweenOrderByTimestampAsc(User user, Instant startTime, Instant endTime, boolean includeSynthetic, boolean includeIgnored, boolean includeInvalid) { + public List findByUserAndTimestampBetweenOrderByTimestampAsc(User user, Instant startTime, Instant endTime, boolean includeSynthetic) { StringBuilder sql = new StringBuilder() - .append("SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.invalid, rlp.ignored, rlp.version ") + .append("SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version ") .append("FROM raw_location_points rlp ") .append("WHERE rlp.user_id = ? "); if (!includeSynthetic) { sql.append("AND rlp.synthetic = false "); } - if (!includeIgnored) { - sql.append("AND rlp.ignored = false "); - } - if (!includeInvalid) { - sql.append("AND rlp.invalid = false "); - } sql.append("AND rlp.timestamp >= ? AND rlp.timestamp < ? ORDER BY rlp.timestamp"); return jdbcTemplate.query(sql.toString(), rawLocationPointRowMapper, user.getId(), Timestamp.from(startTime), Timestamp.from(endTime)); } public List findByUserAndTimestampBetweenOrderByTimestampAsc( - User user, Instant startTime, Instant endTime, boolean includeSynthetic, boolean includeIgnored, boolean includeInvalid, int page, int pageSize) { + User user, Instant startTime, Instant endTime, boolean includeSynthetic, int page, int pageSize) { StringBuilder sql = new StringBuilder() - .append("SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.invalid, rlp.ignored, rlp.version ") + .append("SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version ") .append("FROM raw_location_points rlp ") .append("WHERE rlp.user_id = ? "); if (!includeSynthetic) { sql.append("AND rlp.synthetic = false "); } - if (!includeIgnored) { - sql.append("AND rlp.ignored = false "); - } - if (!includeInvalid) { - sql.append("AND rlp.invalid = false "); - } sql.append("AND rlp.timestamp >= ? AND rlp.timestamp < ? ORDER BY rlp.timestamp") .append(" OFFSET ").append(page * pageSize).append(" LIMIT ").append(pageSize); return jdbcTemplate.query(sql.toString(), rawLocationPointRowMapper, user.getId(), Timestamp.from(startTime), Timestamp.from(endTime)); } - @SuppressWarnings("DataFlowIssue") - public long countByUserAndTimestampBetweenOrderByTimestampAsc( - User user, Instant startTime, Instant endTime, boolean includeSynthetic, boolean includeIgnored, boolean includeInvalid) { - StringBuilder sql = new StringBuilder() - .append("SELECT COUNT(*)") - .append("FROM raw_location_points rlp ") - .append("WHERE rlp.user_id = ? "); - if (!includeSynthetic) { - sql.append("AND rlp.synthetic = false "); - } - if (!includeIgnored) { - sql.append("AND rlp.ignored = false "); - } - if (!includeInvalid) { - sql.append("AND rlp.invalid = false "); - } - sql.append("AND rlp.timestamp >= ? AND rlp.timestamp < ? "); - return jdbcTemplate.queryForObject(sql.toString(), Long.class, - user.getId(), Timestamp.from(startTime), Timestamp.from(endTime)); - } - public List findByUserAndProcessedIsFalseOrderByTimestampWithLimit(User user, int limit, int offset) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.invalid, rlp.version " + + String sql = "SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM raw_location_points rlp " + - "WHERE rlp.user_id = ? AND rlp.processed = false AND rlp.invalid = false " + + "WHERE rlp.user_id = ? AND rlp.processed = false " + "ORDER BY rlp.timestamp " + "LIMIT ? OFFSET ?"; return jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), limit, offset); } public List findDistinctYearsByUser(User user) { - String sql = "SELECT DISTINCT EXTRACT(YEAR FROM timestamp) " + - "FROM raw_location_points " + - "WHERE user_id = ? AND invalid = false " + - "ORDER BY EXTRACT(YEAR FROM timestamp) DESC"; + String sql = """ + SELECT DISTINCT EXTRACT(YEAR FROM day) + FROM location_daily_summary + WHERE user_id = ? + ORDER BY EXTRACT(YEAR FROM day) DESC + """; return jdbcTemplate.queryForList(sql, Integer.class, user.getId()); } public RawLocationPoint create(User user, RawLocationPoint rawLocationPoint) { - String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, elevation_meters, geom, processed, synthetic, invalid, ignored) " + - "VALUES (?, ?, ?, ?, ST_GeomFromText(?, '4326'), ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING id"; + String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, elevation_meters, geom, processed, synthetic) " + + "VALUES (?, ?, ?, ?, ST_GeomFromText(?, '4326'), ?, ?) ON CONFLICT DO NOTHING RETURNING id"; Long id = jdbcTemplate.queryForObject(sql, Long.class, user.getId(), Timestamp.from(rawLocationPoint.getTimestamp()), @@ -149,15 +116,13 @@ public RawLocationPoint create(User user, RawLocationPoint rawLocationPoint) { rawLocationPoint.getElevationMeters(), pointReaderWriter.write(rawLocationPoint.getGeom()), rawLocationPoint.isProcessed(), - rawLocationPoint.isSynthetic(), - rawLocationPoint.isInvalid(), - rawLocationPoint.isIgnored() + rawLocationPoint.isSynthetic() ); return rawLocationPoint.withId(id); } public RawLocationPoint update(RawLocationPoint rawLocationPoint) { - String sql = "UPDATE raw_location_points SET timestamp = ?, accuracy_meters = ?, elevation_meters = ?, geom = ST_GeomFromText(?, '4326'), processed = ?, synthetic = ?, invalid = ?, ignored = ? WHERE id = ?"; + String sql = "UPDATE raw_location_points SET timestamp = ?, accuracy_meters = ?, elevation_meters = ?, geom = ST_GeomFromText(?, '4326'), processed = ?, synthetic = ? WHERE id = ?"; jdbcTemplate.update(sql, Timestamp.from(rawLocationPoint.getTimestamp()), rawLocationPoint.getAccuracyMeters(), @@ -165,15 +130,13 @@ public RawLocationPoint update(RawLocationPoint rawLocationPoint) { pointReaderWriter.write(rawLocationPoint.getGeom()), rawLocationPoint.isProcessed(), rawLocationPoint.isSynthetic(), - rawLocationPoint.isInvalid(), - rawLocationPoint.isIgnored(), rawLocationPoint.getId() ); return rawLocationPoint; } public Optional findById(Long id) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.invalid, rlp.ignored, rlp.version " + + String sql = "SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM raw_location_points rlp " + "WHERE rlp.id = ?"; List results = jdbcTemplate.query(sql, rawLocationPointRowMapper, id); @@ -181,18 +144,18 @@ public Optional findById(Long id) { } public Optional findLatest(User user, Instant since) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.invalid, rlp.ignored, rlp.version " + + String sql = "SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM raw_location_points rlp " + - "WHERE rlp.user_id = ? AND rlp.timestamp >= ? AND rlp.invalid = false " + + "WHERE rlp.user_id = ? AND rlp.timestamp >= ? " + "ORDER BY rlp.timestamp LIMIT 1"; List results = jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), Timestamp.from(since)); return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); } public Optional findLatest(User user) { - String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.invalid, rlp.version " + + String sql = "SELECT rlp.id, rlp.source_point_id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.version " + "FROM raw_location_points rlp " + - "WHERE rlp.user_id = ? AND rlp.invalid = false AND ignored = false " + + "WHERE rlp.user_id = ? " + "ORDER BY rlp.timestamp DESC LIMIT 1"; List results = jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId()); return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); @@ -221,7 +184,6 @@ SELECT COUNT(*) WHERE user_id = ? AND ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)) AND timestamp >= ?::timestamp AND timestamp < ?::timestamp - AND ignored = false AND invalid = false LIMIT ? ) """; @@ -240,14 +202,13 @@ AND ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)) WITH box_filtered_points AS ( SELECT id, + source_point_id, user_id, timestamp, geom, accuracy_meters, elevation_meters, processed, - ignored, - invalid, synthetic, version, ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)) as in_box, @@ -258,10 +219,10 @@ WITH box_filtered_points AS ( FROM raw_location_points WHERE user_id = ? AND timestamp >= ?::timestamp AND timestamp < ?::timestamp - AND ignored = false AND invalid = false ) SELECT id, + source_point_id, user_id, timestamp, ST_AsText(geom) as geom, @@ -269,8 +230,6 @@ WITH box_filtered_points AS ( elevation_meters, processed, synthetic, - ignored, - invalid, version FROM box_filtered_points WHERE in_box = true @@ -297,14 +256,13 @@ WITH box_filtered_points AS ( WITH box_filtered_points AS ( SELECT id, + source_point_id, user_id, timestamp, geom, accuracy_meters, elevation_meters, processed, - ignored, - invalid, synthetic, version, ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)) as in_box, @@ -315,7 +273,6 @@ WITH box_filtered_points AS ( FROM raw_location_points WHERE user_id = ? AND timestamp >= ?::timestamp AND timestamp < ?::timestamp - AND ignored = false AND invalid = false ), relevant_points AS ( SELECT * @@ -326,18 +283,17 @@ relevant_points AS ( ), sampled_points AS ( SELECT DISTINCT ON ( - date_trunc('hour', timestamp) + + date_trunc('hour', timestamp) + (EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes' ) id, + source_point_id, user_id, timestamp, geom, accuracy_meters, elevation_meters, processed, - invalid, - ignored, synthetic, version FROM relevant_points @@ -348,6 +304,7 @@ SELECT DISTINCT ON ( ) SELECT id, + source_point_id, user_id, timestamp, ST_AsText(geom) as geom, @@ -355,8 +312,6 @@ SELECT DISTINCT ON ( elevation_meters, processed, synthetic, - ignored, - invalid, version FROM sampled_points ORDER BY timestamp @@ -375,9 +330,9 @@ SELECT DISTINCT ON ( public List findSimplifiedRouteForPeriod( User user, Instant startTime, - Instant endTime, - int maxPoints) { + Instant endTime) { + int maxPoints = 10000; // Calculate sampling interval based on time range and desired point count Duration period = Duration.between(startTime, endTime); long intervalMinutes = Math.max(1, period.toMinutes() / maxPoints); @@ -389,19 +344,17 @@ SELECT DISTINCT ON ( (EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes' ) id, + source_point_id, timestamp, geom, accuracy_meters, elevation_meters, processed, synthetic, - invalid, - ignored, version FROM raw_location_points WHERE user_id = ? AND timestamp >= ? AND timestamp < ? - AND ignored = false AND invalid = false ORDER BY date_trunc('hour', timestamp) + (EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes', @@ -409,14 +362,13 @@ SELECT DISTINCT ON ( ) SELECT id, + source_point_id, accuracy_meters, elevation_meters, timestamp, ST_AsText(geom) as geom, processed, synthetic, - ignored, - invalid, version FROM sampled_points ORDER BY timestamp @@ -433,28 +385,6 @@ public long countByUser(User user) { return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points WHERE user_id = ?", Long.class, user.getId()); } - public int bulkInsert(User user, List points) { - if (points.isEmpty()) { - return -1; - } - - String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, elevation_meters, geom, processed, synthetic, invalid, ignored) " + - "VALUES (?, ?, ?, ?, CAST(? AS geometry), false, false, false, false) ON CONFLICT DO NOTHING;"; - - List batchArgs = new ArrayList<>(); - for (LocationPoint point : points) { - batchArgs.add(new Object[]{ - user.getId(), - Timestamp.from(point.getTimestamp()), - point.getAccuracyMeters(), - point.getElevationMeters(), - geometryFactory.createPoint(new Coordinate(point.getLongitude(), point.getLatitude())).toString() - }); - } - int[] ints = jdbcTemplate.batchUpdate(sql, batchArgs); - return Arrays.stream(ints).sum(); - } - public void bulkUpdateProcessedStatus(List points) { if (points.isEmpty()) { return; @@ -469,39 +399,6 @@ public void bulkUpdateProcessedStatus(List points) { jdbcTemplate.batchUpdate(sql, batchArgs); } - public void resetInvalidStatus(User user, Instant startTime, Instant endTime) { - String sql = "UPDATE raw_location_points SET invalid = false WHERE user_id = ? AND timestamp >= ? AND timestamp < ?"; - jdbcTemplate.update(sql, user.getId(), Timestamp.from(startTime), Timestamp.from(endTime)); - } - - public void bulkUpdateInvalidStatus(List points) { - if (points.isEmpty()) { - return; - } - - String sql = "UPDATE raw_location_points SET invalid = true, processed = true WHERE id = ?"; - - List batchArgs = points.stream() - .map(point -> new Object[]{point.getId()}) - .collect(Collectors.toList()); - - jdbcTemplate.batchUpdate(sql, batchArgs); - } - - public void bulkUpdateIgnoredStatus(List pointIds, boolean ignored) { - if (pointIds.isEmpty()) { - return; - } - - String sql = "UPDATE raw_location_points SET ignored = ?, processed = true WHERE id = ?"; - - List batchArgs = pointIds.stream() - .map(pointId -> new Object[]{ignored, pointId}) - .collect(Collectors.toList()); - - jdbcTemplate.batchUpdate(sql, batchArgs); - } - public void deleteAll() { String sql = "DELETE FROM raw_location_points"; jdbcTemplate.update(sql); @@ -550,8 +447,8 @@ public int bulkInsertSynthetic(User user, List syntheticPoints) { return 0; } - String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, elevation_meters, geom, processed, synthetic, invalid, ignored) " + - "VALUES (?, ?, ?, ?, CAST(? AS geometry), false, true, false, false) ON CONFLICT DO NOTHING;"; + String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, elevation_meters, geom, processed, synthetic) " + + "VALUES (?, ?, ?, ?, CAST(? AS geometry), false, true) ON CONFLICT DO NOTHING;"; List batchArgs = new ArrayList<>(); for (LocationPoint point : syntheticPoints) { @@ -587,8 +484,6 @@ public MapMetadata getMetadata(User user, Instant start, Instant end) { ST_XMax(ST_Extent(geom)) as max_lng FROM raw_location_points WHERE user_id = ? - AND invalid = false - AND ignored = false AND timestamp >= ? AND timestamp < ? """ : """ SELECT @@ -603,7 +498,7 @@ public MapMetadata getMetadata(User user, Instant start, Instant end) { WHERE user_id = ? AND day >= ?::date AND day < ?::date """; - MapMetadata result = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> new MapMetadata( + return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> new MapMetadata( rs.getLong("min_ts"), rs.getLong("max_ts"), rs.getLong("total_count"), @@ -621,6 +516,25 @@ public MapMetadata getMetadata(User user, Instant start, Instant end) { return locationPoint; }) ), user.getId(), Timestamp.from(start), Timestamp.from(end)); - return result; + } + + public long countUnprocessedByUser(User user) { + return this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points WHERE user_id = ? AND processed = false", Long.class, user.getId()); + } + + public void dropForReSeeding(User user, TimeRange timeRange) { + this.jdbcTemplate.update("DELETE FROM raw_location_points WHERE user_id = ? AND timestamp >= ? AND timestamp <= ?", user.getId(), Timestamp.from(timeRange.start()), Timestamp.from(timeRange.end())); + } + + public int updateFromDevices(User user, TimeRange timeRange) { + return this.jdbcTemplate.update(""" + INSERT INTO raw_location_points + (accuracy_meters, timestamp, user_id, geom, elevation_meters, source_point_id, processed, synthetic) + SELECT + accuracy_meters, timestamp, user_id, geom, elevation_meters, source_point_id, FALSE, FALSE + FROM v_source_stream + WHERE user_id = ? AND timestamp >= ? AND timestamp <= ? + """ + ,user.getId(), Timestamp.from(timeRange.start()), Timestamp.from(timeRange.end())); } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/SourceLocationPointJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/SourceLocationPointJdbcService.java new file mode 100644 index 000000000..c4fda8a80 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/SourceLocationPointJdbcService.java @@ -0,0 +1,241 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.dto.MapMetadata; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.processing.DeviceTimeRange; +import com.dedicatedcode.reitti.service.processing.TimeRange; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Array; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@Transactional +public class SourceLocationPointJdbcService { + private static final int NO_PAGING = -1; + private final JdbcTemplate jdbcTemplate; + private final RowMapper rawLocationPointRowMapper; + private final PointReaderWriter pointReaderWriter; + private final GeometryFactory geometryFactory; + + public SourceLocationPointJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter, GeometryFactory geometryFactory) { + this.jdbcTemplate = jdbcTemplate; + this.rawLocationPointRowMapper = (rs, _) -> new SourceLocationPoint( + rs.getLong("id"), + rs.getTimestamp("timestamp").toInstant(), + pointReaderWriter.read(rs.getString("geom")), + rs.getDouble("accuracy_meters"), + rs.getObject("elevation_meters", Double.class), + SourceLocationPoint.Status.fromDbValue(rs.getLong("status")), + rs.getBoolean("invalid") + ); + + this.pointReaderWriter = pointReaderWriter; + this.geometryFactory = geometryFactory; + } + + public List findByUserAndTimestampBetweenOrderByTimestampAsc(User user, Device device, Instant startTime, Instant endTime, boolean includeIgnored, boolean includeInvalid, int page, int size) { + StringBuilder sql = new StringBuilder() + .append("SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.invalid, rlp.status ") + .append("FROM raw_source_points rlp ") + .append("WHERE rlp.user_id = ? AND rlp.device_id IS NOT DISTINCT FROM ? "); + if (!includeIgnored) { + sql.append("AND rlp.status = 0 "); + } + if (!includeInvalid) { + sql.append("AND rlp.invalid = false "); + } + sql.append("AND rlp.timestamp >= ? AND rlp.timestamp < ? ORDER BY rlp.timestamp"); + if (page != NO_PAGING && size != NO_PAGING) { + sql.append(" OFFSET ").append(page * size).append(" LIMIT ").append(size); + } + return jdbcTemplate.query(sql.toString(), rawLocationPointRowMapper, + user.getId(), device != null ? device.id() : null, Timestamp.from(startTime), Timestamp.from(endTime)); + } + + @SuppressWarnings("DataFlowIssue") + public long countByUserAndTimestampBetween(User user, Device device, Instant startTime, Instant endTime, boolean includeIgnored, boolean includeInvalid) { + StringBuilder sql = new StringBuilder() + .append("SELECT count(*) ") + .append("FROM raw_source_points rlp ") + .append("WHERE rlp.user_id = ? AND rlp.device_id IS NOT DISTINCT FROM ? "); + if (!includeIgnored) { + sql.append("AND rlp.status = 0 "); + } + if (!includeInvalid) { + sql.append("AND rlp.invalid = false "); + } + sql.append("AND rlp.timestamp >= ? AND rlp.timestamp < ?"); + return jdbcTemplate.queryForObject(sql.toString(), Long.class, + user.getId(), device != null ? device.id() : null, Timestamp.from(startTime), Timestamp.from(endTime)); + } + public List findByUserAndTimestampBetweenOrderByTimestampAsc(User user, Device device, Instant startTime, Instant endTime, boolean includeIgnored, boolean includeInvalid) { + return findByUserAndTimestampBetweenOrderByTimestampAsc(user, device, startTime, endTime, includeIgnored, includeInvalid, NO_PAGING, NO_PAGING); + } + + public SourceLocationPoint create(User user, Device device, SourceLocationPoint rawLocationPoint) { + String sql = "INSERT INTO raw_source_points (user_id, device_id, timestamp, accuracy_meters, elevation_meters, geom, invalid, status) " + + "VALUES (?, ?, ?, ?, ?, ST_GeomFromText(?, '4326'), ?, ?) ON CONFLICT DO NOTHING RETURNING id"; + Long id = jdbcTemplate.queryForObject(sql, Long.class, + user.getId(), + device != null ? device.id() : null, + Timestamp.from(rawLocationPoint.getTimestamp()), + rawLocationPoint.getAccuracyMeters(), + rawLocationPoint.getElevationMeters(), + pointReaderWriter.write(rawLocationPoint.getGeom()), + rawLocationPoint.isInvalid(), + rawLocationPoint.getStatus().getDbValue() + ); + return rawLocationPoint.withId(id); + } + + public int bulkInsert(User user, Device device, List points) { + if (points.isEmpty()) { + return NO_PAGING; + } + + String sql = "INSERT INTO raw_source_points (user_id, device_id, timestamp, accuracy_meters, elevation_meters, geom, invalid, status) " + + "VALUES (?, ?, ?, ?, ?, CAST(? AS geometry), false, 0) ON CONFLICT DO NOTHING;"; + + List batchArgs = new ArrayList<>(); + for (LocationPoint point : points) { + batchArgs.add(new Object[]{ + user.getId(), + device != null ? device.id() : null, + Timestamp.from(point.getTimestamp()), + point.getAccuracyMeters(), + point.getElevationMeters(), + geometryFactory.createPoint(new Coordinate(point.getLongitude(), point.getLatitude())).toString() + }); + } + int[] ints = jdbcTemplate.batchUpdate(sql, batchArgs); + return Arrays.stream(ints).sum(); + } + + public void resetInvalidStatus(User user, Instant startTime, Instant endTime) { + String sql = "UPDATE raw_source_points SET invalid = false WHERE user_id = ? AND timestamp >= ? AND timestamp < ?"; + jdbcTemplate.update(sql, user.getId(), Timestamp.from(startTime), Timestamp.from(endTime)); + } + + public void bulkUpdateInvalidStatus(List points) { + if (points.isEmpty()) { + return; + } + + String sql = "UPDATE raw_source_points SET invalid = true WHERE id = ?"; + + List batchArgs = points.stream() + .map(point -> new Object[]{point.getId()}) + .collect(Collectors.toList()); + + jdbcTemplate.batchUpdate(sql, batchArgs); + } + + public void bulkUpdateIgnoredStatus(User user, List pointIds) { + + SourceLocationPoint.Status ignoredStatus = SourceLocationPoint.Status.IGNORED_BY_SYSTEM; + updateBulkStatus(user, pointIds, ignoredStatus); + } + + public void bulkUpdateManuallyIgnoredStatus(User user, List pointIds) { + updateBulkStatus(user, pointIds, SourceLocationPoint.Status.IGNORED_BY_USER); + } + + public List findAffectedTimeRange(User user, List pointIds) { + String sql = "SELECT device_id, MIN(timestamp), MAX(timestamp) FROM raw_source_points WHERE user_id = ? AND id = ANY(?) GROUP BY device_id"; + + return jdbcTemplate.query(sql, ps -> { + Long[] idArray = pointIds.toArray(new Long[0]); + Array sqlArray = ps.getConnection().createArrayOf("bigint", idArray); + ps.setLong(1, user.getId()); + ps.setArray(2, sqlArray); + }, ((rs, rowNum) -> new DeviceTimeRange((Long)rs.getObject("device_id"), TimeRange.of(rs.getTimestamp("min").toInstant(), rs.getTimestamp("max").toInstant())))); + } + + private void updateBulkStatus(User user, List pointIds, SourceLocationPoint.Status ignoredStatus) { + if (pointIds.isEmpty()) { + return; + } + String sql = "UPDATE raw_source_points SET status = ? WHERE id = ? AND user_id = ?"; + + List batchArgs = pointIds.stream() + .map(pointId -> new Object[]{ignoredStatus.getDbValue(), pointId, user.getId()}) + .collect(Collectors.toList()); + + jdbcTemplate.batchUpdate(sql, batchArgs); + } + + + public int updateLocation(User user, Long id, double lat, double lng) { + return this.jdbcTemplate.update("UPDATE raw_source_points SET geom = CAST(? AS geometry) WHERE id = ? AND user_id = ?", + geometryFactory.createPoint(new Coordinate(lng, lat)).toString() + , id, user.getId()); + } + + public MapMetadata getMetadata(User user, Device device, Instant start, Instant end) { + + String sql = """ + SELECT + EXTRACT(EPOCH FROM MIN(timestamp)) as min_ts, + EXTRACT(EPOCH FROM MAX(timestamp)) as max_ts, + COUNT(*) as total_count, + ST_YMin(ST_Extent(geom)) as min_lat, + ST_YMax(ST_Extent(geom)) as max_lat, + ST_XMin(ST_Extent(geom)) as min_lng, + ST_XMax(ST_Extent(geom)) as max_lng + FROM raw_source_points + WHERE user_id = ? + AND device_id = ? + AND timestamp >= ? AND timestamp < ? + """; + return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> new MapMetadata( + rs.getLong("min_ts"), + rs.getLong("max_ts"), + rs.getLong("total_count"), + rs.getDouble("min_lat"), + rs.getDouble("max_lat"), + rs.getDouble("min_lng"), + rs.getDouble("max_lng"), + this.findLatest(user, device).map(latestPoint -> { + LocationPoint locationPoint = new LocationPoint(); + locationPoint.setTimestamp(latestPoint.getTimestamp()); + locationPoint.setLatitude(latestPoint.getLatitude()); + locationPoint.setLongitude(latestPoint.getLongitude()); + locationPoint.setAccuracyMeters(latestPoint.getAccuracyMeters()); + locationPoint.setElevationMeters(latestPoint.getElevationMeters()); + return locationPoint; + }) + ), user.getId(), device.id(), Timestamp.from(start), Timestamp.from(end)); + } + + public Optional findLatest(User user, Device device) { + String sql = """ + SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, status, invalid + FROM raw_source_points rlp + WHERE rlp.user_id = ? AND rlp.device_id = ? + ORDER BY rlp.timestamp DESC LIMIT 1"""; + List results = jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), device.id()); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + + public void deleteAllForUser(User user) { + this.jdbcTemplate.update("DELETE FROM raw_source_points WHERE user_id = ?", user.getId()); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/TimelineOverviewStatisticsService.java b/src/main/java/com/dedicatedcode/reitti/repository/TimelineOverviewStatisticsService.java new file mode 100644 index 000000000..091b48ae1 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/TimelineOverviewStatisticsService.java @@ -0,0 +1,380 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.dto.timeline.GroupedTimelineEntry; +import com.dedicatedcode.reitti.model.geo.TransportMode; +import com.dedicatedcode.reitti.model.metadata.Mood; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.ContextPathHolder; +import com.dedicatedcode.reitti.service.I18nService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.sql.Timestamp; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.*; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class TimelineOverviewStatisticsService { + + private static final Logger log = LoggerFactory.getLogger(TimelineOverviewStatisticsService.class); + private final NamedParameterJdbcTemplate jdbcTemplate; + private final I18nService i18nService; + private final ContextPathHolder contextPathHolder; + + public TimelineOverviewStatisticsService(NamedParameterJdbcTemplate jdbcTemplate, I18nService i18nService, ContextPathHolder contextPathHolder) { + this.jdbcTemplate = jdbcTemplate; + this.i18nService = i18nService; + this.contextPathHolder = contextPathHolder; + } + + public List load(User user, Instant start, Instant end, ZoneId userTimezone) { + + Duration duration = Duration.between(start, end.plus(1, ChronoUnit.SECONDS)); + Granularity granularity = duration.toDays() >= 365 ? Granularity.MONTHLY : Granularity.WEEKLY; + + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("granularity", granularity.getSqlValue()) + .addValue("timezone", userTimezone.toString()) + .addValue("userId", user.getId()) + .addValue("start", Timestamp.from(start)) + .addValue("end", Timestamp.from(end)); + + List> allTripsByLower = this.jdbcTemplate.queryForList(""" + SELECT + d.day AT TIME ZONE :timezone AS time_bucket, + COUNT(t.id) AS amount + FROM ( + SELECT GENERATE_SERIES( + :start::timestamptz, + :end::timestamptz, + '1 day'::interval + ) AS day + ) d + LEFT JOIN trips t ON + DATE_TRUNC('day', t.start_time AT TIME ZONE :timezone) AT TIME ZONE :timezone = d.day + AND t.user_id = :userId + GROUP BY d.day + ORDER BY d.day; + """, params); + + List> allVisitsByLower = this.jdbcTemplate.queryForList(""" + SELECT + d.day AT TIME ZONE :timezone AS time_bucket, + COUNT(pv.id) AS amount + FROM ( + SELECT GENERATE_SERIES( + :start::timestamptz, + :end::timestamptz, + '1 day'::interval + ) AS day + ) d + LEFT JOIN processed_visits pv ON + DATE_TRUNC('day', pv.start_time AT TIME ZONE :timezone) AT TIME ZONE :timezone = d.day + AND pv.user_id = :userId + GROUP BY d.day + ORDER BY d.day; + """, params); + + List> tripMoodCountsPerSlice = this.jdbcTemplate.queryForList(""" + SELECT + DATE_TRUNC(:granularity, t.start_time AT TIME ZONE :timezone) AT TIME ZONE :timezone AS time_bucket, + t.transport_mode_inferred AS name, + lm.metadata->>'mood' AS mood, + SUM(t.duration_seconds)::BIGINT AS duration_seconds, + SUM(t.travelled_distance_meters)::BIGINT AS distance_meters, + COUNT(*) AS mood_count + FROM trips t + LEFT JOIN LATERAL ( + SELECT metadata + FROM location_metadata lm + WHERE lm.user_id = :userId + AND lm.time_range && TSTZRANGE(t.start_time, t.end_time) + AND lm.context_type = 'TRIP' + AND lm.metadata->>'mood' IS NOT NULL + ORDER BY UPPER(lm.time_range * TSTZRANGE(t.start_time, t.end_time)) + - LOWER(lm.time_range * TSTZRANGE(t.start_time, t.end_time)) DESC + LIMIT 1 + ) lm ON TRUE + WHERE t.user_id = :userId + AND t.start_time >= :start AND t.start_time <= :end + AND t.start_time < t.end_time + GROUP BY 1, 2, 3 + ORDER BY time_bucket; + """, params); + + List> visitMoodCountsPerSlice = this.jdbcTemplate.queryForList(""" + SELECT + DATE_TRUNC(:granularity, v.start_time AT TIME ZONE :timezone) AT TIME ZONE :timezone AS time_bucket, + lm.metadata->>'mood' AS mood, + SUM(v.duration_seconds)::BIGINT AS duration_seconds, + COUNT(*) AS mood_count + FROM processed_visits v + LEFT JOIN LATERAL ( + SELECT metadata + FROM location_metadata lm + WHERE lm.user_id = :userId + AND lm.time_range && TSTZRANGE(v.start_time, v.end_time) + AND lm.context_type = 'VISIT' + AND lm.metadata->>'mood' IS NOT NULL + ORDER BY UPPER(lm.time_range * TSTZRANGE(v.start_time, v.end_time)) + - LOWER(lm.time_range * TSTZRANGE(v.start_time, v.end_time)) DESC + LIMIT 1 + ) lm ON TRUE + WHERE v.user_id = :userId + AND v.start_time >= :start AND v.start_time <= :end + AND v.start_time < v.end_time + GROUP BY 1, 2 + ORDER BY time_bucket; + """, params); + + + List> visitCountsPerSlice = this.jdbcTemplate.queryForList(""" + SELECT + DATE_TRUNC(:granularity, v.start_time AT TIME ZONE :timezone) AT TIME ZONE :timezone AS time_bucket, + lm.metadata->>'mood' AS mood, + v.id, + s.id AS place_id, + s.name AS place_name, + SUM(v.duration_seconds)::BIGINT AS duration_seconds, + COUNT(*) AS mood_count + FROM processed_visits v + LEFT JOIN LATERAL ( + SELECT metadata + FROM location_metadata lm + WHERE lm.user_id = :userId + AND lm.time_range && TSTZRANGE(v.start_time, v.end_time) + AND lm.context_type = 'VISIT' + AND lm.metadata->>'mood' IS NOT NULL + ORDER BY UPPER(lm.time_range * TSTZRANGE(v.start_time, v.end_time)) + - LOWER(lm.time_range * TSTZRANGE(v.start_time, v.end_time)) DESC + LIMIT 1 + ) lm ON TRUE + LEFT JOIN significant_places s ON s.id = v.place_id + WHERE v.user_id = :userId + AND v.start_time >= :start AND v.start_time <= :end + GROUP BY 1, 2, 3, 4, 5 + ORDER BY time_bucket; + """, params); + + List> visits = this.jdbcTemplate.queryForList(""" + SELECT + DATE_TRUNC(:granularity, pv.start_time AT TIME ZONE :timezone) AT TIME ZONE :timezone AS time_bucket, + sp.name AS place_name, + COUNT(pv.id) AS amount + FROM processed_visits pv + LEFT JOIN significant_places sp ON pv.place_id = sp.id + WHERE pv.user_id = :userId + AND pv.start_time >= :start + AND pv.start_time <= :end + GROUP BY 1, 2 + ORDER BY time_bucket, amount DESC; + """, params); + Locale locale = LocaleContextHolder.getLocale(); + List entries = new ArrayList<>(); + + Map>> splitTrips = splitupIntoDays(start, userTimezone, granularity, allTripsByLower); + Map>> splitVisits = splitupIntoDays(start, userTimezone, granularity, allVisitsByLower); + + Map amountOfTrips = calculateAmountPerSlice(granularity, userTimezone, locale, allTripsByLower); + Map amountOfPlaces = calculateAmountPerSlice(granularity, userTimezone, locale, visits); + + Map>> transportModeData = calculateTransportModeData(userTimezone, tripMoodCountsPerSlice); + Map>> visitsMoodDurationData = calculateVisitsDataPerPlace(userTimezone, visitCountsPerSlice); + + Map> visitMoods = calculateMoodRingValue(userTimezone, visitMoodCountsPerSlice); + Map> tripMoods = calculateMoodRingValue(userTimezone, tripMoodCountsPerSlice); + + Set allExistingGroupKeys = extractKeys(splitTrips, splitVisits, transportModeData, visitMoods, tripMoods); + + List sortedKeys = allExistingGroupKeys.stream().sorted().toList(); + + for (LocalDate sortedKey : sortedKeys) { + LocalDate endDate = granularity == Granularity.MONTHLY ? sortedKey.withDayOfMonth(sortedKey.lengthOfMonth()) : sortedKey.with(ChronoField.DAY_OF_WEEK, 1).plusWeeks(1); + List> tripsPerSegment = splitTrips.get(sortedKey); + List> visitsPerSegment = splitVisits.get(sortedKey); + List overviewEntries = new ArrayList<>(); + for (int i = 0; i < visitsPerSegment.size(); i++) { + LocalDate day = visitsPerSegment.get(i).get("time_bucket") != null ? ((Timestamp) visitsPerSegment.get(i).get("time_bucket")).toInstant().atZone(userTimezone).toLocalDate() : null; + long dailyAmountOfVisits = visitsPerSegment.get(i).get("amount") != null ? (long) visitsPerSegment.get(i).get("amount") : 0; + long dailyAmountOfTrips = tripsPerSegment.get(i).get("amount") != null ? (long) tripsPerSegment.get(i).get("amount") : 0; + overviewEntries.add(new GroupedTimelineEntry.OverviewEntry(day, dailyAmountOfVisits, dailyAmountOfTrips)); + } + + if (overviewEntries.stream().mapToLong(o -> o.visits() + o.trips()).sum() == 0) { + log.debug("Skipping empty timeline entry for {}", sortedKey); + continue; + } + + Map> transportModeListMap = transportModeData.getOrDefault(sortedKey, Collections.emptyMap()); + List transportEntries = new ArrayList<>(); + transportModeListMap.forEach((transportMode, transportModeParts) -> transportEntries.add(new GroupedTimelineEntry.TransportEntry(transportMode, transportModeParts))); + + + Map> visitMoodDurationMap = visitsMoodDurationData.getOrDefault(sortedKey, Collections.emptyMap()); + List visitEntries = new ArrayList<>(); + visitMoodDurationMap.forEach((placeName, visitMoodDurationParts) -> visitEntries.add(new GroupedTimelineEntry.VisitEntry(placeName, visitMoodDurationParts))); + + String name; + String subheadline; + if (granularity == Granularity.MONTHLY) { + name = sortedKey.format(DateTimeFormatter.ofPattern("MMMM yyyy").withLocale(locale)); + } else { + name = i18nService.translate("timeline.grouped.headline.weekly", sortedKey.get(WeekFields.of(locale).weekOfYear())); + } + LocalDate rangeEnd = granularity == Granularity.MONTHLY ? + sortedKey.withDayOfMonth(1).plusMonths(1).minusDays(1) : + sortedKey.with(ChronoField.DAY_OF_WEEK, 1).plusWeeks(1).minusDays(1); + subheadline = i18nService.translate("js.common.time-range", sortedKey.format(DateTimeFormatter.ofPattern("dd.MM").withLocale(locale)), rangeEnd.format(DateTimeFormatter.ofPattern("dd.MM.yyyy").withLocale(locale))); + entries.add(new GroupedTimelineEntry(UUID.randomUUID(), + name, + subheadline, + String.format(contextPathHolder.getContextPath() + "/?startDate=%s&endDate=%s", sortedKey, endDate), + overviewEntries, + amountOfPlaces.get(sortedKey), + amountOfTrips.get(sortedKey), + visitMoods.get(sortedKey), + tripMoods.get(sortedKey), + transportEntries, + visitEntries)); + } + return entries; + } + + private Set extractKeys(Map>> amountOfTrips, Map>> splitVisits, Map>> transportModeData, Map> visitMoods, Map> tripMoods) { + Set allExistingGroupKeys = new HashSet<>(); + allExistingGroupKeys.addAll(amountOfTrips.keySet()); + allExistingGroupKeys.addAll(splitVisits.keySet()); + allExistingGroupKeys.addAll(transportModeData.keySet()); + allExistingGroupKeys.addAll(visitMoods.keySet()); + allExistingGroupKeys.addAll(tripMoods.keySet()); + + return allExistingGroupKeys; + } + + private Map> calculateMoodRingValue(ZoneId userTimezone, List> visitMoodCountsPerSlice) { + Map> result = new HashMap<>(); + Map>> byGranularity = visitMoodCountsPerSlice.stream().collect(Collectors.groupingBy(t -> ((Timestamp) t.get("time_bucket")).toInstant().atZone(userTimezone).toLocalDate())); + for (LocalDate localDate : byGranularity.keySet()) { + List moodRing = new ArrayList<>(); + List> byDate = byGranularity.get(localDate); + for (Map stringObjectMap : byDate) { + long amount = (Long) stringObjectMap.get("mood_count"); + long duration = (Long) stringObjectMap.get("duration_seconds"); + Mood mood = stringObjectMap.get("mood") != null ? Mood.valueOf((String) stringObjectMap.get("mood")) : null; + moodRing.add(new GroupedTimelineEntry.MoodValue(mood, amount, duration)); + } + result.put(localDate, moodRing); + } + + return result; + + } + + private Map>> calculateTransportModeData(ZoneId userTimezone, List> tripMoodCountsPerSlice) { + Map>> result = new HashMap<>(); + Map>> byGranularity = tripMoodCountsPerSlice.stream().collect(Collectors.groupingBy(t -> ((Timestamp) t.get("time_bucket")).toInstant().atZone(userTimezone).toLocalDate())); + + for (LocalDate localDate : byGranularity.keySet()) { + Map> transportModesByDate = new HashMap<>(); + Map>> byTransportMode = byGranularity.get(localDate).stream().collect(Collectors.groupingBy(t -> TransportMode.valueOf((String) t.get("name")))); + for (TransportMode transportMode : byTransportMode.keySet()) { + List> transportModeData = byTransportMode.get(transportMode); + long totalTripDuration = transportModeData.stream().mapToLong(t -> (Long) t.get("duration_seconds")).sum(); + List transportModeParts = transportModeData.stream().map(t -> { + long moodDuration = (long) t.get("duration_seconds"); + double percent = (double) moodDuration / (double) totalTripDuration; + return new GroupedTimelineEntry.TransportModePart(transportMode, t.get("mood") != null ? Mood.valueOf((String) t.get("mood")) : null, (long) t.get("duration_seconds"), percent); + }).sorted((o1, o2) -> (int) (o2.percent() - o1.percent())).toList(); + transportModesByDate.put(transportMode, transportModeParts); + } + result.put(localDate, transportModesByDate); + } + return result; + } + + private Map>> calculateVisitsDataPerPlace(ZoneId userTimezone, List> visitMoodsPerSlice) { + Map>> result = new HashMap<>(); + Map>> byGranularity = visitMoodsPerSlice.stream().collect(Collectors.groupingBy(t -> ((Timestamp) t.get("time_bucket")).toInstant().atZone(userTimezone).toLocalDate())); + + for (LocalDate localDate : byGranularity.keySet()) { + Map> parts = new HashMap<>(); + Map>> byDateAndName = byGranularity.get(localDate).stream().collect(Collectors.groupingBy(t -> t.get("place_name") != null ? (String) t.get("place_name") : "")); + + for (String placeName : byDateAndName.keySet()) { + List> partValues = byDateAndName.get(placeName); + long totalDuration = partValues.stream().mapToLong(t -> (Long) t.get("duration_seconds")).sum(); + for (Map partValue : partValues) { + long duration = (long) partValue.get("duration_seconds"); + Long placeId = (Long) partValue.get("place_id"); + String cleaned = StringUtils.hasText(placeName) ? placeName : null; + String moodValue = (String) partValue.get("mood"); + Mood mood = moodValue != null ? Mood.valueOf(moodValue) : null; + parts.computeIfAbsent(cleaned, _ -> new ArrayList<>()).add(new GroupedTimelineEntry.VisitPart(placeId, cleaned, mood, duration, (double) duration / (double) totalDuration)); + } + } + if (!parts.isEmpty()) { + result.put(localDate, parts); + } + } + return result; + } + + private Map calculateAmountPerSlice(Granularity granularity, ZoneId userTimezone, Locale locale, List> entries) { + TemporalAdjuster temporalAdjuster = granularity == Granularity.WEEKLY ? + TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY) : + TemporalAdjusters.firstDayOfMonth(); + Map>> timeBucket = entries.stream() + .collect(Collectors.groupingBy( + t -> ((Timestamp) t.get("time_bucket")).toInstant().atZone(userTimezone).toLocalDate() + .with(temporalAdjuster) + )); + Map timeBucketMap = new HashMap<>(); + for (LocalDate localDate : timeBucket.keySet()) { + timeBucketMap.put(localDate, timeBucket.get(localDate).stream().mapToLong(t -> (Long) t.get("amount")).sum()); + } + return timeBucketMap; + } + + private Map>> splitupIntoDays(Instant start, ZoneId userTimezone, Granularity safeGranularity, List> allTripsByLower) { + LocalDate current = start.atZone(userTimezone).toLocalDate(); + LocalDate currentSlotStart = safeGranularity == Granularity.MONTHLY ? current.withDayOfMonth(1) : current.with(ChronoField.DAY_OF_WEEK, 1); + LocalDate currentSlotEnd = safeGranularity == Granularity.MONTHLY ? current.withDayOfMonth(1).plusMonths(1) : current.with(ChronoField.DAY_OF_WEEK, 1).plusWeeks(1); + Map>> splitTrips = new HashMap<>(); + splitTrips.put(currentSlotStart, new ArrayList<>()); + + for (Map stringObjectMap : allTripsByLower) { + Timestamp timeBucket = (Timestamp) stringObjectMap.get("time_bucket"); + LocalDate currentTimeDay = timeBucket.toLocalDateTime().toLocalDate(); + if (!currentTimeDay.isBefore(currentSlotEnd)) { + currentSlotStart = currentSlotEnd; + currentSlotEnd = safeGranularity == Granularity.MONTHLY ? currentSlotEnd.plusMonths(1) : currentSlotEnd.plusWeeks(1); + } + splitTrips.computeIfAbsent(currentSlotStart, _ -> new ArrayList<>()).add(stringObjectMap); + + } + return splitTrips; + } + + private enum Granularity { + WEEKLY("week"), + MONTHLY("month"); + + private final String sqlValue; + + Granularity(String sqlValue) { + this.sqlValue = sqlValue; + } + + public String getSqlValue() { + return sqlValue; + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java index bbc7b2d1e..2425c96cf 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java @@ -5,6 +5,9 @@ import com.dedicatedcode.reitti.model.geo.TransportMode; import com.dedicatedcode.reitti.model.geo.Trip; import com.dedicatedcode.reitti.model.security.User; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; @@ -15,6 +18,7 @@ import java.sql.Timestamp; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -24,9 +28,12 @@ public class TripJdbcService { private final JdbcTemplate jdbcTemplate; private final ProcessedVisitJdbcService processedVisitJdbcService; - public TripJdbcService(JdbcTemplate jdbcTemplate, ProcessedVisitJdbcService processedVisitJdbcService) { + private final ObjectMapper objectMapper; + + public TripJdbcService(JdbcTemplate jdbcTemplate, ProcessedVisitJdbcService processedVisitJdbcService, ObjectMapper objectMapper) { this.jdbcTemplate = jdbcTemplate; this.processedVisitJdbcService = processedVisitJdbcService; + this.objectMapper = objectMapper; } private final RowMapper TRIP_ROW_MAPPER = new RowMapper<>() { @@ -34,18 +41,26 @@ public TripJdbcService(JdbcTemplate jdbcTemplate, ProcessedVisitJdbcService proc public Trip mapRow(ResultSet rs, int rowNum) throws SQLException { ProcessedVisit startVisit = processedVisitJdbcService.findById(rs.getLong("start_visit_id")).orElseThrow(); ProcessedVisit endVisit = processedVisitJdbcService.findById(rs.getLong("end_visit_id")).orElseThrow(); - return new Trip( - rs.getLong("id"), - rs.getTimestamp("start_time").toInstant(), - rs.getTimestamp("end_time").toInstant(), - rs.getLong("duration_seconds"), - rs.getDouble("estimated_distance_meters"), - rs.getDouble("travelled_distance_meters"), - TransportMode.valueOf(rs.getString("transport_mode_inferred")), - startVisit, - endVisit, - rs.getLong("version") - ); + try { + String metadataValue = rs.getString("metadata"); + Map metadata = metadataValue != null ? objectMapper.readValue(metadataValue, new TypeReference<>() {}) : null; + return new Trip( + rs.getLong("id"), + rs.getTimestamp("start_time").toInstant(), + rs.getTimestamp("end_time").toInstant(), + rs.getLong("duration_seconds"), + rs.getDouble("estimated_distance_meters"), + rs.getDouble("travelled_distance_meters"), + TransportMode.valueOf(rs.getString("transport_mode_inferred")), + startVisit, + endVisit, + metadata, + rs.getLong("version") + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } }; @@ -106,8 +121,8 @@ public List findTransportStatisticsByUserAndTimeRange(User user, Insta } public Trip create(User user, Trip trip) { - String sql = "INSERT INTO trips (user_id, start_time, end_time, duration_seconds, travelled_distance_meters, transport_mode_inferred, start_visit_id, end_visit_id, version) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING id"; + String sql = "INSERT INTO trips (user_id, start_time, end_time, duration_seconds, travelled_distance_meters, transport_mode_inferred, start_visit_id, end_visit_id, metadata, version) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, 1) RETURNING id"; Long id = jdbcTemplate.queryForObject(sql, Long.class, user.getId(), Timestamp.from(trip.getStartTime()), @@ -116,13 +131,14 @@ public Trip create(User user, Trip trip) { trip.getTravelledDistanceMeters(), trip.getTransportModeInferred().name(), trip.getStartVisit() != null ? trip.getStartVisit().getId() : null, - trip.getEndVisit() != null ? trip.getEndVisit().getId() : null + trip.getEndVisit() != null ? trip.getEndVisit().getId() : null, + asJson(trip.getMetadata()) ); return trip.withId(id); } public Trip update(Trip trip) { - String sql = "UPDATE trips SET start_time = ?, end_time = ?, duration_seconds = ?, travelled_distance_meters = ?, transport_mode_inferred = ?, start_visit_id = ?, end_visit_id = ?, version = ? WHERE id = ?"; + String sql = "UPDATE trips SET start_time = ?, end_time = ?, duration_seconds = ?, travelled_distance_meters = ?, transport_mode_inferred = ?, start_visit_id = ?, end_visit_id = ?, metadata = ?::jsonb, version = ? WHERE id = ?"; jdbcTemplate.update(sql, Timestamp.from(trip.getStartTime()), Timestamp.from(trip.getEndTime()), @@ -131,6 +147,7 @@ public Trip update(Trip trip) { trip.getTransportModeInferred().name(), trip.getStartVisit() != null ? trip.getStartVisit().getId() : null, trip.getEndVisit() != null ? trip.getEndVisit().getId() : null, + asJson(trip.getMetadata()), trip.getVersion() + 1, trip.getId() ); @@ -152,8 +169,8 @@ public List bulkInsert(User user, List tripsToInsert) { String sql = """ INSERT INTO trips (user_id, start_visit_id, end_visit_id, start_time, end_time, - duration_seconds, estimated_distance_meters, travelled_distance_meters, transport_mode_inferred, version) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING; + duration_seconds, estimated_distance_meters, travelled_distance_meters, transport_mode_inferred, metadata, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?) ON CONFLICT DO NOTHING; """; List batchArgs = tripsToInsert.stream() @@ -167,6 +184,7 @@ INSERT INTO trips (user_id, start_visit_id, end_visit_id, start_time, end_time, trip.getEstimatedDistanceMeters(), trip.getTravelledDistanceMeters(), trip.getTransportModeInferred().name(), + asJson(trip.getMetadata()), trip.getVersion() }) .collect(Collectors.toList()); @@ -224,4 +242,13 @@ OR end_visit_id IN (SELECT id FROM processed_visits WHERE place_id = ANY(?))) idList, idList); } + + + private String asJson(Object value) { + try { + return this.objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserJdbcService.java index eb2c77574..a9d176334 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/UserJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserJdbcService.java @@ -12,6 +12,9 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.Temporal; import java.util.List; import java.util.Optional; @@ -128,4 +131,19 @@ private User mapRowToUser(ResultSet rs, int rowNum) throws SQLException { rs.getLong("version") ); } + + public void setLastDataModificationAt(User user, Instant lastDataModificationAt) { + this.jdbcTemplate.update("UPDATE users SET last_data_modified_at = ? WHERE id = ?", Timestamp.from(lastDataModificationAt), user.getId()); + } + + public Optional getLastDataModificationAt(User user) { + return this.jdbcTemplate.queryForObject("SELECT last_data_modified_at FROM users WHERE id = ?", (rs, rowNum) -> { + Timestamp lastDataModifiedAt = rs.getTimestamp("last_data_modified_at"); + if (lastDataModifiedAt == null) { + return Optional.empty(); + } else { + return Optional.of(lastDataModifiedAt.toInstant()); + } + }, user.getId()); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java new file mode 100644 index 000000000..6a3ce2482 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserMapStyleJdbcService.java @@ -0,0 +1,190 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +@Service +public class UserMapStyleJdbcService { + + private final JdbcTemplate jdbcTemplate; + + public UserMapStyleJdbcService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private final RowMapper rowMapper = (rs, _) -> new UserMapStyle( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("name"), + rs.getString("map_type"), + rs.getString("style_input_type"), + rs.getString("raster_source_input_type"), + rs.getString("style_json"), + rs.getString("style_url"), + new MapStyleDataSource( + rs.getString("source_id"), + rs.getString("source_type"), + rs.getString("tilejson_url"), + rs.getString("tile_url_template"), + rs.getString("attribution"), + (Integer) rs.getObject("minzoom"), + (Integer) rs.getObject("maxzoom"), + (Integer) rs.getObject("tile_size"), + rs.getString("scheme"), + rs.getBoolean("proxy_tiles") + ), + new MapStyleVectorOptions( + rs.getString("attribution_override"), + rs.getString("glyphs_url_override"), + rs.getString("sprite_url_override") + ), + rs.getBoolean("shared"), + rs.getBoolean("default_style"), + rs.getLong("version") + ); + + public List findAll(User user) { + List query = jdbcTemplate.query( + "SELECT * FROM user_map_styles WHERE user_id = ? OR shared = TRUE ORDER BY shared, name, id", + rowMapper, + user.getId() + ); + return query.stream().sorted(Comparator.comparing(UserMapStyle::defaultStyle).reversed().thenComparing(UserMapStyle::id)).toList(); + } + + public Optional findById(User user, long id) { + List results = jdbcTemplate.query( + "SELECT * FROM user_map_styles WHERE id = ? AND (user_id = ? OR shared = TRUE)", + rowMapper, + id, + user.getId() + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + private Optional findOwnedById(User user, long id) { + List results = jdbcTemplate.query( + "SELECT * FROM user_map_styles WHERE user_id = ? AND id = ?", + rowMapper, + user.getId(), + id + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); + } + + public Long getActiveStyleId(User user) { + List results = jdbcTemplate.queryForList( + "SELECT active_style_id FROM user_map_style_settings WHERE user_id = ?", + Long.class, + user.getId() + ); + return results.getFirst(); + } + + @Transactional + public void setActiveStyleId(User user, Long activeStyleId) { + jdbcTemplate.update(""" + INSERT INTO user_map_style_settings (user_id, active_style_id) + VALUES (?, ?) + ON CONFLICT (user_id) DO UPDATE SET active_style_id = EXCLUDED.active_style_id, updated_at = CURRENT_TIMESTAMP + """, user.getId(), activeStyleId); + } + + @Transactional + @CacheEvict(cacheNames = {"mapStyleJson", "mapStyles"}, allEntries = true) + public UserMapStyle save(User user, UserMapStyle style) { + if (style.id() != null) { + Optional existing = findById(user, style.id()); + if (existing.isPresent() && existing.get().defaultStyle()) { + throw new UnsupportedOperationException("Default styles cannot be modified."); + } + } + + if (style.id() != null && findOwnedById(user, style.id()).isPresent()) { + jdbcTemplate.update(""" + UPDATE user_map_styles + SET name = ?, map_type = ?, style_input_type = ?, raster_source_input_type = ?, + style_json = ?, style_url = ?, source_id = ?, source_type = ?, tilejson_url = ?, + tile_url_template = ?, attribution = ?, minzoom = ?, maxzoom = ?, tile_size = ?, scheme = ?, + proxy_tiles = ?, attribution_override = ?, glyphs_url_override = ?, sprite_url_override = ?, + shared = ?, default_style = ?, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE user_id = ? AND id = ? + """, + style.name(), + style.mapType(), + style.styleInputType(), + style.rasterSourceInputType(), + style.styleJson(), + style.styleUrl(), + style.dataSource().sourceId(), + style.dataSource().type(), + style.dataSource().tileJsonUrl(), + style.dataSource().tileUrlTemplate(), + style.dataSource().attribution(), + style.dataSource().minzoom(), + style.dataSource().maxzoom(), + style.dataSource().tileSize(), + style.dataSource().scheme(), + style.dataSource().proxyTiles(), + style.vectorOptions().attributionOverride(), + style.vectorOptions().glyphsUrlOverride(), + style.vectorOptions().spriteUrlOverride(), + style.shared(), + style.defaultStyle(), + user.getId(), + style.id()); + return findOwnedById(user, style.id()).orElseThrow(); + } + + Long id = jdbcTemplate.queryForObject(""" + INSERT INTO user_map_styles + (user_id, name, map_type, style_input_type, raster_source_input_type, style_json, style_url, + source_id, source_type, tilejson_url, tile_url_template, attribution, minzoom, maxzoom, tile_size, scheme, + proxy_tiles, attribution_override, glyphs_url_override, sprite_url_override, shared, default_style) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id + """, + Long.class, + user.getId(), + style.name(), + style.mapType(), + style.styleInputType(), + style.rasterSourceInputType(), + style.styleJson(), + style.styleUrl(), + style.dataSource().sourceId(), + style.dataSource().type(), + style.dataSource().tileJsonUrl(), + style.dataSource().tileUrlTemplate(), + style.dataSource().attribution(), + style.dataSource().minzoom(), + style.dataSource().maxzoom(), + style.dataSource().tileSize(), + style.dataSource().scheme(), + style.dataSource().proxyTiles(), + style.vectorOptions().attributionOverride(), + style.vectorOptions().glyphsUrlOverride(), + style.vectorOptions().spriteUrlOverride(), + style.shared(), + style.defaultStyle()); + return findOwnedById(user, id).orElseThrow(); + } + + @Transactional + @CacheEvict(cacheNames = {"mapStyleJson", "mapStyles"}, allEntries = true) + public void delete(long id) { + jdbcTemplate.update("UPDATE user_map_style_settings SET active_style_id = (SELECT id FROM user_map_styles WHERE name = 'Reitti' LIMIT 1) WHERE active_style_id = ?", id); + jdbcTemplate.update("DELETE FROM user_map_styles WHERE id = ?", id); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java index e9177b861..088fa5a07 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/UserSettingsJdbcService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import java.sql.Timestamp; +import java.time.Instant; import java.time.ZoneId; import java.util.Comparator; import java.util.List; @@ -34,7 +35,6 @@ public UserSettingsJdbcService(JdbcTemplate jdbcTemplate) { Timestamp newestData = rs.getTimestamp("latest_data"); return new UserSettings( userId, - rs.getBoolean("prefer_colored_map"), Language.valueOf(rs.getString("selected_language")), UnitSystem.valueOf(rs.getString("unit_system")), rs.getDouble("home_lat"), @@ -66,9 +66,8 @@ public Optional findByUserId(Long userId) { public UserSettings save(UserSettings userSettings) { if (userSettings.getVersion() == null) { // Insert new settings - this.jdbcTemplate.update("INSERT INTO user_settings (user_id, prefer_colored_map, selected_language, unit_system, home_lat, home_lng, time_zone_override, time_display_mode, time_mode, custom_css, latest_data, color, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", + this.jdbcTemplate.update("INSERT INTO user_settings (user_id, selected_language, unit_system, home_lat, home_lng, time_zone_override, time_display_mode, time_mode, custom_css, latest_data, color, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", userSettings.getUserId(), - userSettings.isPreferColoredMap(), userSettings.getSelectedLanguage().name(), userSettings.getUnitSystem().name(), userSettings.getHomeLatitude(), @@ -84,8 +83,7 @@ public UserSettings save(UserSettings userSettings) { } else { // Update existing settings jdbcTemplate.update( - "UPDATE user_settings SET prefer_colored_map = ?, selected_language = ?, unit_system = ?, home_lat = ?, home_lng = ?, time_zone_override = ?, time_display_mode = ?, time_mode = ?, custom_css = ?, latest_data = GREATEST(latest_data, ?), color = ?, version = version + 1 WHERE user_id = ?", - userSettings.isPreferColoredMap(), + "UPDATE user_settings SET selected_language = ?, unit_system = ?, home_lat = ?, home_lng = ?, time_zone_override = ?, time_display_mode = ?, time_mode = ?, custom_css = ?, latest_data = GREATEST(latest_data, ?), color = ?, version = version + 1 WHERE user_id = ?", userSettings.getSelectedLanguage().name(), userSettings.getUnitSystem().name(), userSettings.getHomeLatitude(), @@ -115,6 +113,11 @@ public void updateNewestData(User user, List filtered) { }); } + @CacheEvict(cacheNames = "user-settings", key = "#user.id") + public void updateNewestData(User user, Instant timestamp) { + this.jdbcTemplate.update("UPDATE user_settings SET latest_data = GREATEST(latest_data, ?) WHERE user_id = ?", Timestamp.from(timestamp), user.getId()); + } + @CacheEvict(cacheNames = "user-settings", key = "#user.id") public void deleteFor(User user) { this.jdbcTemplate.update("DELETE FROM user_settings WHERE user_id = ?", user.getId()); diff --git a/src/main/java/com/dedicatedcode/reitti/service/ApiTokenService.java b/src/main/java/com/dedicatedcode/reitti/service/ApiTokenService.java index b7c6c0c68..1cf7e8daf 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/ApiTokenService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/ApiTokenService.java @@ -21,6 +21,13 @@ public ApiTokenService(ApiTokenJdbcService apiTokenJdbcService) { this.apiTokenJdbcService = apiTokenJdbcService; } + + public Optional getToken(String token) { + return this.apiTokenJdbcService + .findByToken(token) + .map(this::updateLastUsed); + } + public Optional getUserByToken(String token) { return apiTokenJdbcService.findByToken(token) .map(this::updateLastUsed) @@ -51,4 +58,9 @@ public List getRecentUsagesForUser(User user, int maxRows) { public void trackUsage(String token, String requestPath, String remoteIp) { this.apiTokenJdbcService.trackUsage(token, requestPath, remoteIp); } + + public Optional getTokenById(User user, Long id) { + return this.apiTokenJdbcService.findById(id); + } + } diff --git a/src/main/java/com/dedicatedcode/reitti/service/AvatarService.java b/src/main/java/com/dedicatedcode/reitti/service/AvatarService.java index a16545384..5c4608879 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/AvatarService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/AvatarService.java @@ -45,6 +45,25 @@ public Optional getAvatarByUserId(Long userId) { } } + @Cacheable(value = "avatarData", key = "#userId + '_' + #deviceId") + public Optional getAvatarDeviceId(Long userId, Long deviceId) { + try { + Map result = jdbcTemplate.queryForMap( + "SELECT mime_type, binary_data, updated_at FROM device_avatars WHERE user_id = ? AND device_id = ?", + userId, deviceId + ); + + String contentType = (String) result.get("mime_type"); + long updatedAt = ((Timestamp) result.get("updated_at")).getTime(); + byte[] imageData = (byte[]) result.get("binary_data"); + + return Optional.of(new AvatarData(contentType, imageData, updatedAt)); + + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + public Optional getInfo(Long userId) { try { Map result = jdbcTemplate.queryForMap( @@ -61,6 +80,22 @@ public Optional getInfo(Long userId) { } } + public Optional getInfo(Long userId, Long deviceId) { + try { + Map result = jdbcTemplate.queryForMap( + "SELECT updated_at FROM device_avatars WHERE user_id = ? AND device_id = ?", + userId, deviceId + ); + + long updatedAt = ((Timestamp) result.get("updated_at")).getTime(); + + return Optional.of(new AvatarInfo(updatedAt)); + + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + @CacheEvict(value = {"avatarThumbnails", "avatarData"}, key = "{#userId}") public void updateAvatar(Long userId, String contentType, byte[] imageData) { @@ -73,12 +108,30 @@ public void updateAvatar(Long userId, String contentType, byte[] imageData) { imageData ); } + @CacheEvict(value = {"avatarThumbnails", "avatarData"}, key = "#userId + '_' + #deviceId") + public void updateAvatar(Long userId, Long deviceId, String contentType, byte[] imageData) { + + jdbcTemplate.update("DELETE FROM device_avatars WHERE user_id = ? AND device_id = ?", userId, deviceId); + jdbcTemplate.update( + "INSERT INTO device_avatars (user_id, device_id, mime_type, binary_data) " + + "VALUES (?, ?, ?, ?) ", + userId, + deviceId, + contentType, + imageData + ); + } @CacheEvict(value = {"avatarThumbnails", "avatarData"}, key = "{#userId}") public void deleteAvatar(Long userId) { this.jdbcTemplate.update("DELETE FROM user_avatars WHERE user_id = ?", userId); } + @CacheEvict(value = {"avatarThumbnails", "avatarData"}, key = "#userId + '_' + #deviceId") + public void deleteAvatar(Long userId, Long deviceId) { + this.jdbcTemplate.update("DELETE FROM device_avatars WHERE user_id = ? AND device_id = ?", userId, deviceId); + } + public String generateInitials(String displayName) { if (displayName == null || displayName.trim().isEmpty()) { return ""; @@ -123,6 +176,23 @@ public Optional getAvatarThumbnail(Long userId, int width, int height) { }); } + @Cacheable(value = "avatarThumbnails", key = "{#userId, #deviceId, #width, #height}") + public Optional getAvatarThumbnail(Long userId, Long deviceId, int width, int height) { + return getAvatarDeviceId(userId, deviceId).map(avatarData -> { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + Thumbnails.of(new ByteArrayInputStream(avatarData.imageData())) + .size(width, height) + .outputFormat(avatarData.mimeType().contains("png") ? "png" : "jpg") + .outputQuality(0.75) + .toOutputStream(output); + return output.toByteArray(); + } catch (IOException e) { + log.error("Failed to generate thumbnail for avatar of user [{}] and device [{}]", userId, deviceId, e); + return null; + } + }); + } + public record AvatarData(String mimeType, byte[] imageData, long updatedAt) implements Serializable {} public record AvatarInfo(long updatedAt) implements Serializable {} diff --git a/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java b/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java index 10b18707c..f22b79253 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java @@ -1,18 +1,24 @@ package com.dedicatedcode.reitti.service; +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; import com.dedicatedcode.reitti.repository.TripJdbcService; -import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.UUID; + +import static com.dedicatedcode.reitti.service.jobs.JobType.VISIT_TRIP_DETECTION; @Service public class DataCleanupService { @@ -21,23 +27,43 @@ public class DataCleanupService { private final ProcessedVisitJdbcService processedVisitJdbcService; private final SignificantPlaceJdbcService significantPlaceJdbcService; private final RawLocationPointJdbcService rawLocationPointJdbcService; - private final DefaultImportProcessor defaultImportProcessor; + private final JobSchedulingService jobScheduler; + private final SignificantPlaceJdbcService placeJdbcService; + private final Task processingEventTask; + public DataCleanupService(TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService, RawLocationPointJdbcService rawLocationPointJdbcService, - DefaultImportProcessor defaultImportProcessor) { + JobSchedulingService jobScheduler, SignificantPlaceJdbcService placeJdbcService, + Task processingEventTask) { this.tripJdbcService = tripJdbcService; this.processedVisitJdbcService = processedVisitJdbcService; this.significantPlaceJdbcService = significantPlaceJdbcService; this.rawLocationPointJdbcService = rawLocationPointJdbcService; - this.defaultImportProcessor = defaultImportProcessor; + this.jobScheduler = jobScheduler; + this.placeJdbcService = placeJdbcService; + this.processingEventTask = processingEventTask; + } + + public void execute(TaskData taskData) { + User user = taskData.user; + SignificantPlace updatedPlace = taskData.place; + Long placeId = updatedPlace.getId(); + + List placesToRemove = placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, updatedPlace.getPolygon()); + List placesToCheck = new ArrayList<>(placesToRemove); + placesToCheck.add(updatedPlace); + List affectedDays = this.processedVisitJdbcService.getAffectedDays(placesToCheck); + cleanupForGeometryChange(user, placesToRemove, affectedDays, taskData.jobId); } - public void cleanupForGeometryChange(User user, List placesToRemove, List affectedDays) { + public void cleanupForGeometryChange(User user, List placesToRemove, List affectedDays, UUID jobId) { long start = System.nanoTime(); + log.info("Cleanup for geometry change. Removing [{}] places and starting recalculation for days [{}]", placesToRemove.size(), affectedDays); + log.debug("Removing affected trips for places [{}]", placesToRemove); this.tripJdbcService.deleteFor(user, placesToRemove); log.debug("Removing affected visits for places [{}]", placesToRemove); @@ -49,7 +75,39 @@ public void cleanupForGeometryChange(User user, List placesToR start = System.nanoTime(); this.rawLocationPointJdbcService.markAllAsUnprocessedForUser(user, affectedDays); log.info("clearing processed points for days [{}] completed in {}ms", affectedDays, (System.nanoTime() - start) / 1000000); - this.defaultImportProcessor.scheduleProcessingTrigger(user.getUsername()); + + jobScheduler.enqueueTask(processingEventTask, + new TriggerProcessingEvent(user.getUsername(), null, null).withParentJobId(jobId), + JobSchedulingService.Metadata.builder().jobType(VISIT_TRIP_DETECTION) + .user(user) + .friendlyName("Detect Visits and Trips").build() + ); + } + + public static class TaskData extends JobContext { + + private final User user; + private final SignificantPlace place; + + public TaskData(User user, SignificantPlace place) { + this(user, place, null, null); + } + + public TaskData(User user, SignificantPlace place, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + this.place = place; + } + + @Override + public TaskData withJobId(UUID jobId) { + return new TaskData(user, place, jobId, parentJobId); + } + + @Override + public TaskData withParentJobId(UUID parentJobId) { + return new TaskData(user, place, jobId, parentJobId); + } } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java deleted file mode 100644 index 8d92b8f8c..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import com.dedicatedcode.reitti.dto.LocationPoint; -import com.dedicatedcode.reitti.event.TriggerProcessingEvent; -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.processing.LocationDataIngestPipeline; -import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; -import jakarta.annotation.PreDestroy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.*; - -@Component -public class DefaultImportProcessor implements ImportProcessor { - - private static final Logger logger = LoggerFactory.getLogger(DefaultImportProcessor.class); - - private final LocationDataIngestPipeline locationDataIngestPipeline; - private final int batchSize; - private final int processingIdleStartTime; - private final ProcessingPipelineTrigger processingPipelineTrigger; - private final ScheduledExecutorService scheduler; - private final ConcurrentHashMap> pendingTriggers; - private final ThreadPoolExecutor importExecutors = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); - - public DefaultImportProcessor( - LocationDataIngestPipeline locationDataIngestPipeline, - @Value("${reitti.import.batch-size:10000}") int batchSize, - @Value("${reitti.import.processing-idle-start-time:15}") int processingIdleStartTime, - ProcessingPipelineTrigger processingPipelineTrigger) { - this.locationDataIngestPipeline = locationDataIngestPipeline; - this.batchSize = batchSize; - this.processingIdleStartTime = processingIdleStartTime; - this.processingPipelineTrigger = processingPipelineTrigger; - this.scheduler = Executors.newScheduledThreadPool(2); - this.pendingTriggers = new ConcurrentHashMap<>(); - } - - @Override - public void processBatch(User user, List batch) { - logger.debug("Sending batch of [{}] locations for user [{}] into executor queue", batch.size(), user.getUsername()); - List points = new ArrayList<>(batch); - this.importExecutors.submit(() -> { - logger.trace("Sending batch of {} locations for storing", points.size()); - locationDataIngestPipeline.processLocationData(user.getUsername(), points); - logger.trace("Sending batch of {} locations for processing", points.size()); - scheduleProcessingTrigger(user.getUsername()); - }); - } - - @Override - public void scheduleProcessingTrigger(String username) { - { - ScheduledFuture existingTrigger = pendingTriggers.get(username); - if (existingTrigger != null && !existingTrigger.isDone()) { - existingTrigger.cancel(false); - } - - ScheduledFuture newTrigger = scheduler.schedule(() -> { - try { - logger.debug("Triggered processing for user: {}", username); - TriggerProcessingEvent triggerEvent = new TriggerProcessingEvent(username, null, UUID.randomUUID().toString()); - processingPipelineTrigger.handle(triggerEvent, false); - pendingTriggers.remove(username); - } catch (Exception e) { - logger.error("Failed to trigger processing for user: {}", username, e); - } - }, processingIdleStartTime, TimeUnit.SECONDS); - - pendingTriggers.put(username, newTrigger); - } - } - - @Override - public boolean isIdle() { - return importExecutors.getQueue().isEmpty() && - pendingTriggers.isEmpty() || pendingTriggers.values().stream().allMatch(ScheduledFuture::isDone); - } - - @PreDestroy - public void shutdown() { - importExecutors.shutdown(); - scheduler.shutdown(); - try { - if (!importExecutors.awaitTermination(30, TimeUnit.SECONDS)) { - importExecutors.shutdownNow(); - } - if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { - scheduler.shutdownNow(); - } - } catch (InterruptedException e) { - importExecutors.shutdownNow(); - scheduler.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - - public int getBatchSize() { - return batchSize; - } - - public int getPendingTaskCount() { - return importExecutors.getQueue().size(); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/DynamicMqttProvider.java b/src/main/java/com/dedicatedcode/reitti/service/DynamicMqttProvider.java index 1e8294de2..c22db3eb4 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/DynamicMqttProvider.java +++ b/src/main/java/com/dedicatedcode/reitti/service/DynamicMqttProvider.java @@ -1,14 +1,14 @@ package com.dedicatedcode.reitti.service; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; import com.dedicatedcode.reitti.repository.MqttIntegrationJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; import com.dedicatedcode.reitti.service.integration.mqtt.MqttIntegration; import com.dedicatedcode.reitti.service.integration.mqtt.MqttPayloadProcessor; import com.dedicatedcode.reitti.service.integration.mqtt.PayloadType; -import com.hivemq.client.internal.mqtt.MqttClientSslConfigImpl; import com.hivemq.client.mqtt.MqttClient; -import com.hivemq.client.mqtt.MqttClientSslConfig; import com.hivemq.client.mqtt.datatypes.MqttQos; import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientBuilder; @@ -33,12 +33,17 @@ public class DynamicMqttProvider { private static final Logger log = LoggerFactory.getLogger(DynamicMqttProvider.class); private final UserJdbcService userJdbcService; + private final DeviceJdbcService deviceJdbcService; private final MqttIntegrationJdbcService repository; private final Map processors; private final ConcurrentHashMap activeClients = new ConcurrentHashMap<>(); - public DynamicMqttProvider(UserJdbcService userJdbcService, MqttIntegrationJdbcService repository, List processorList) { + public DynamicMqttProvider(UserJdbcService userJdbcService, + DeviceJdbcService deviceJdbcService, + MqttIntegrationJdbcService repository, + List processorList) { this.userJdbcService = userJdbcService; + this.deviceJdbcService = deviceJdbcService; this.repository = repository; this.processors = processorList.stream() .collect(Collectors.toMap(MqttPayloadProcessor::getSupportedType, p -> p)); @@ -48,6 +53,7 @@ public DynamicMqttProvider(UserJdbcService userJdbcService, MqttIntegrationJdbcS public void reconnectAllOnStartup() { this.userJdbcService.getAllUsers() .forEach(user -> this.repository.findByUser(user) + .filter(MqttIntegration::isEnabled) .ifPresent(config -> connectClient(user, config))); } @@ -111,6 +117,7 @@ private void connectClient(User user, MqttIntegration config) { Mqtt3ConnectBuilder.Send> builder = client.connectWith() .cleanSession(false); + Device device = this.deviceJdbcService.getDefaultDevice(user); if (StringUtils.hasText(config.getUsername())) { builder.simpleAuth() .username(config.getUsername()) @@ -124,7 +131,7 @@ private void connectClient(User user, MqttIntegration config) { client.subscribeWith() .topicFilter(config.getTopic()) .qos(MqttQos.AT_LEAST_ONCE) - .callback(publish -> dispatch(user, config, publish.getPayloadAsBytes())) + .callback(publish -> dispatch(user, device, config, publish.getPayloadAsBytes())) .send(); }).exceptionally(throwable -> { log.error("Error connecting client [{}] for user {} to {} on {}", config.getIdentifier(), user.getUsername(), config.getTopic(), config.getHost(), throwable); @@ -132,10 +139,10 @@ private void connectClient(User user, MqttIntegration config) { }); } - private void dispatch(User user, MqttIntegration config, byte[] payload) { + private void dispatch(User user, Device device, MqttIntegration config, byte[] payload) { MqttPayloadProcessor processor = processors.get(config.getPayloadType()); if (processor != null) { - processor.process(user, payload); + processor.process(user, device, payload); } else { log.error("No processor found for type: {}", config.getPayloadType()); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/GeoJsonExportService.java b/src/main/java/com/dedicatedcode/reitti/service/GeoJsonExportService.java new file mode 100644 index 000000000..fd81561b0 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/GeoJsonExportService.java @@ -0,0 +1,183 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.geo.GeoPoint; +import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.Writer; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class GeoJsonExportService { + + private final SourceLocationPointJdbcService sourceLocationPointJdbcService; + private final RawLocationPointJdbcService rawLocationPointRepository; + private final DeviceJdbcService deviceJdbcService; + private final ObjectMapper objectMapper; + + public GeoJsonExportService(SourceLocationPointJdbcService sourceLocationPointJdbcService, RawLocationPointJdbcService rawLocationPointRepository, + DeviceJdbcService deviceJdbcService, + ObjectMapper objectMapper) { + this.sourceLocationPointJdbcService = sourceLocationPointJdbcService; + this.rawLocationPointRepository = rawLocationPointRepository; + this.deviceJdbcService = deviceJdbcService; + this.objectMapper = objectMapper; + } + + public void generateGeoJsonContentStreaming(User user, + Instant start, + Instant end, + Writer writer) throws IOException { + List points = rawLocationPointRepository.findByUserAndTimestampBetweenOrderByTimestampAsc( + user, + start, + end, + false); + + FeatureCollection collection = new FeatureCollection(); + for (RawLocationPoint point : points) { + Feature feature = new Feature(); + feature.setGeometry(createPointGeometry(point.getGeom(), point.getElevationMeters())); + feature.setProperties(createProperties(point.getId(), point.getTimestamp(), point.getAccuracyMeters(), point.getElevationMeters(), null, point.getSourceId())); + collection.getFeatures().add(feature); + } + + objectMapper.writeValue(writer, collection); + } + + public void generateGeoJsonContentStreaming(User user, + Instant start, + Instant end, + Long deviceId, + Writer writer) throws IOException { + Device device = this.deviceJdbcService.find(user, deviceId).orElse(null); + List points = sourceLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc( + user, + device, + start, + end, + true, true); + + FeatureCollection collection = new FeatureCollection(); + for (SourceLocationPoint point : points) { + Feature feature = new Feature(); + feature.setGeometry(createPointGeometry(point.getGeom(), point.getElevationMeters())); + feature.setProperties(createProperties(point.getId(), point.getTimestamp(), point.getAccuracyMeters(), point.getElevationMeters(), device != null ? device.id() : null, point.getId())); + collection.getFeatures().add(feature); + } + + objectMapper.writeValue(writer, collection); + } + + private Geometry createPointGeometry(GeoPoint point, Double elevationMeters) { + List coordinates = new ArrayList<>(); + coordinates.add(point.longitude()); + coordinates.add(point.latitude()); + if (elevationMeters != null) { + coordinates.add(elevationMeters); + } + Geometry geometry = new Geometry(); + geometry.setType("Point"); + geometry.setCoordinates(coordinates); + return geometry; + } + + private Map createProperties(Long id, Instant timestamp, Double accuracyMeters, Double elevationMeters, Long deviceId, Long sourceId) { + Map props = new LinkedHashMap<>(); + props.put("id", id); + props.put("timestamp", timestamp.toString()); + props.put("accuracy", accuracyMeters); + props.put("elevation", elevationMeters); + if (deviceId != null) { + props.put("device", deviceId); + } + if (sourceId != null) { + props.put("sourceId", sourceId); + } + return props; + } + + static class FeatureCollection { + private String type = "FeatureCollection"; + private List features = new ArrayList<>(); + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public List getFeatures() { + return features; + } + + public void setFeatures(List features) { + this.features = features; + } + } + + static class Feature { + private String type = "Feature"; + private Geometry geometry; + private Map properties; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Geometry getGeometry() { + return geometry; + } + + public void setGeometry(Geometry geometry) { + this.geometry = geometry; + } + + public Map getProperties() { + return properties; + } + + public void setProperties(Map properties) { + this.properties = properties; + } + } + + static class Geometry { + private String type; + private List coordinates; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public List getCoordinates() { + return coordinates; + } + + public void setCoordinates(List coordinates) { + this.coordinates = coordinates; + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java b/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java index 64ee65190..65afa853f 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java @@ -1,8 +1,10 @@ package com.dedicatedcode.reitti.service; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService; import org.springframework.stereotype.Service; import java.io.IOException; @@ -14,10 +16,10 @@ @Service public class GpxExportService { - private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final SourceLocationPointJdbcService locationPointJdbcService; - public GpxExportService(RawLocationPointJdbcService rawLocationPointJdbcService) { - this.rawLocationPointJdbcService = rawLocationPointJdbcService; + public GpxExportService(SourceLocationPointJdbcService locationPointJdbcService) { + this.locationPointJdbcService = locationPointJdbcService; } /** @@ -26,14 +28,15 @@ public GpxExportService(RawLocationPointJdbcService rawLocationPointJdbcService) *

* Note: start and end date should be given in UTC * - * @param user the user whose location data will be exported - * @param start the start time of the export range - * @param end the end time of the export range - * @param writer the writer to which the GPX content will be streamed + * @param user the user whose location data will be exported + * @param device + * @param start the start time of the export range + * @param end the end time of the export range + * @param writer the writer to which the GPX content will be streamed * @param relevant controls if we export only the relevant data for processing (true) or only the imported data (false) * @throws IOException if an I/O error occurs during writing */ - public void generateGpxContentStreaming(User user, Instant start, Instant end, Writer writer, boolean relevant) throws IOException { + public void generateGpxContentStreaming(User user, Device device, Instant start, Instant end, Writer writer, boolean relevant) throws IOException { // Write GPX header writer.write("\n"); writer.write("\n"); @@ -51,9 +54,9 @@ public void generateGpxContentStreaming(User user, Instant start, Instant end, W while (!currentDate.isAfter(end)) { Instant nextDate = currentDate.plus(1, ChronoUnit.DAYS); - List points = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, currentDate, nextDate, relevant, !relevant, !relevant); + List points = locationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, device, currentDate, nextDate, !relevant, !relevant); - for (RawLocationPoint point : points) { + for (SourceLocationPoint point : points) { writer.write(" \n"); if (point.getElevationMeters() != null) { diff --git a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java index e4d2819c3..568d7163c 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/I18nService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/I18nService.java @@ -51,10 +51,6 @@ public String translate(String messageKey) { return messageSource.getMessage(messageKey, null, LocaleContextHolder.getLocale()); } - public String translateWithDefault(String messageKey, String defaultMessage) { - return messageSource.getMessage(messageKey, null, defaultMessage, LocaleContextHolder.getLocale()); - } - public String translate(String messageKey, Object... args) { return messageSource.getMessage(messageKey, args, LocaleContextHolder.getLocale()); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/ImportProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/ImportProcessor.java deleted file mode 100644 index 70dac38a1..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/ImportProcessor.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import com.dedicatedcode.reitti.dto.LocationPoint; -import com.dedicatedcode.reitti.model.security.User; - -import java.util.List; - -public interface ImportProcessor { - void processBatch(User user, List batch); - void scheduleProcessingTrigger(String username); - boolean isIdle(); -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/JobContext.java b/src/main/java/com/dedicatedcode/reitti/service/JobContext.java new file mode 100644 index 000000000..d4ad95aac --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/JobContext.java @@ -0,0 +1,28 @@ +package com.dedicatedcode.reitti.service; + +import java.io.Serializable; +import java.util.UUID; + +public abstract class JobContext implements Serializable { + protected final UUID jobId; + protected final UUID parentJobId; + + protected JobContext() { + this(null, null); + } + protected JobContext(UUID jobId, UUID parentJobId) { + this.jobId = jobId; + this.parentJobId = parentJobId; + } + + public abstract T withJobId(UUID jobId); + public abstract T withParentJobId(UUID parentJobId); + + public UUID getJobId() { + return this.jobId; + } + + public UUID getParentJobId() { + return this.parentJobId; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/LocationBatchingService.java b/src/main/java/com/dedicatedcode/reitti/service/LocationBatchingService.java index 494a3bb3d..2089b3d3d 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/LocationBatchingService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/LocationBatchingService.java @@ -1,147 +1,172 @@ package com.dedicatedcode.reitti.service; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; +import com.dedicatedcode.reitti.service.importer.PromotionJobHandler; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.github.kagkarlsson.scheduler.task.Task; import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.event.ContextClosedEvent; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; @Service public class LocationBatchingService { private static final Logger logger = LoggerFactory.getLogger(LocationBatchingService.class); - private final ImportProcessor importProcessor; private final Map userBatches = new ConcurrentHashMap<>(); - private final ReentrantLock flushLock = new ReentrantLock(); - - @Value("${reitti.batching.max-batch-size:100}") - private int maxBatchSize; - - @Value("${reitti.batching.max-wait-time-ms:5000}") - private long maxWaitTimeMs; + private final Set initializedPartitions = ConcurrentHashMap.newKeySet(); + private final LocationPointStagingService locationPointStagingService; + private final Task promotionTask; + private final JobSchedulingService jobScheduler; + + private final int maxBatchSize; + private final long maxWaitTimeMs; @Autowired - public LocationBatchingService(ImportProcessor importProcessor) { - this.importProcessor = importProcessor; + public LocationBatchingService(LocationPointStagingService locationPointStagingService, + Task promotionTask, + JobSchedulingService jobScheduler, + @Value("${reitti.batching.max-batch-size:100}") int maxBatchSize, + @Value("${reitti.batching.max-wait-time-ms:5000}") int maxWaitTimeMs) { + this.locationPointStagingService = locationPointStagingService; + this.promotionTask = promotionTask; + this.jobScheduler = jobScheduler; + this.maxBatchSize = maxBatchSize; + this.maxWaitTimeMs = maxWaitTimeMs; } - - public void addLocationPoint(User user, LocationPoint locationPoint) { - String username = user.getUsername(); - - userBatches.compute(username, (key, existingBatch) -> { + + public void addLocationPoint(User user, Device device, LocationPoint locationPoint) { + String sessionKey = getSessionKey(user, device); + + userBatches.compute(sessionKey, (key, existingBatch) -> { if (existingBatch == null) { - existingBatch = new UserBatch(user); + existingBatch = new UserBatch(user, device, key); } - + existingBatch.addLocationPoint(locationPoint); - - // Check if we should flush this batch immediately + if (existingBatch.shouldFlush(maxBatchSize, maxWaitTimeMs)) { - flushBatch(username, existingBatch); - return new UserBatch(user); + executeFlush(existingBatch); + return null; } - return existingBatch; }); } - - @Scheduled(fixedDelayString = "${reitti.batching.flush-interval-ms:2000}") - public void flushExpiredBatches() { - flushLock.lock(); + + private String getSessionKey(User user, Device device) { + return String.format("stream_%d_%s_%s", + user.getId(), + device != null ? device.id() : "main", + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)) + .toLowerCase(); + } + + private void executeFlush(UserBatch batch) { + if (batch.isEmpty()) return; + try { - List usersToFlush = new ArrayList<>(); - - userBatches.forEach((username, batch) -> { - if (batch.shouldFlush(maxBatchSize, maxWaitTimeMs)) { - usersToFlush.add(username); - } - }); - - for (String username : usersToFlush) { - UserBatch batch = userBatches.remove(username); - if (batch != null && !batch.isEmpty()) { - flushBatch(username, batch); - } + String pKey = batch.getPartitionKey(); + + if (!initializedPartitions.contains(pKey)) { + locationPointStagingService.ensurePartitionExists(pKey); + initializedPartitions.add(pKey); } - } finally { - flushLock.unlock(); + + logger.debug("Flushing batch of {} location points for partition {}", batch.getLocationPoints().size(), pKey); + locationPointStagingService.insertBatch(pKey, + batch.getUser(), + batch.getDevice(), + batch.getLocationPoints() + ); + batch.clear(); + this.jobScheduler.enqueueTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(batch.user, batch.device, pKey, false), + JobSchedulingService.Metadata.builder() + .user(batch.user) + .jobType(JobType.GPS_INGESTION) + .friendlyName("GPS Data Promotion").build()); + } catch (Exception e) { + logger.error("Failed to flush batch for partition {}", batch.getPartitionKey(), e); } } - - @PreDestroy - @EventListener(ContextClosedEvent.class) - public void flushAllBatches() { - logger.info("Application shutting down, flushing all pending location batches"); - flushLock.lock(); - try { - userBatches.forEach((username, batch) -> { - if (!batch.isEmpty()) { - flushBatch(username, batch); - } - }); - userBatches.clear(); - } finally { - flushLock.unlock(); - } + + @Scheduled(fixedDelay = 2000) + public void flushExpiredBatches() { + userBatches.forEach((key, batch) -> { + if (batch.shouldFlush(maxBatchSize, maxWaitTimeMs)) { + userBatches.computeIfPresent(key, (ignored, b) -> { + executeFlush(b); + return null; + }); + } + }); } - - private void flushBatch(String username, UserBatch batch) { - if (batch.isEmpty()) { - return; - } - - try { - List points = batch.getLocationPoints(); - logger.debug("Flushing batch of {} location points for user {}", points.size(), username); - importProcessor.processBatch(batch.getUser(), points); - } catch (Exception e) { - logger.error("Error flushing batch for user {}", username, e); - } + + @PreDestroy + public void onShutdown() { + logger.info("Flushing batches on shutdown..."); + userBatches.forEach((ignored, batch) -> executeFlush(batch)); } - + private static class UserBatch { private final User user; - private final List locationPoints; - private final long createdAt; - - public UserBatch(User user) { + private final Device device; + private final String partitionKey; + private final List locationPoints = new ArrayList<>(); + private final long createdAt = System.currentTimeMillis(); + + public UserBatch(User user, Device device, String partitionKey) { this.user = user; - this.locationPoints = new ArrayList<>(); - this.createdAt = System.currentTimeMillis(); + this.device = device; + this.partitionKey = partitionKey; } - + public void addLocationPoint(LocationPoint point) { locationPoints.add(point); } - - public boolean shouldFlush(int maxBatchSize, long maxWaitTimeMs) { - return locationPoints.size() >= maxBatchSize || - (System.currentTimeMillis() - createdAt) >= maxWaitTimeMs; + + public boolean shouldFlush(int size, long ms) { + return locationPoints.size() >= size || (System.currentTimeMillis() - createdAt) >= ms; } - + public boolean isEmpty() { return locationPoints.isEmpty(); } - + public List getLocationPoints() { - return new ArrayList<>(locationPoints); + return locationPoints; + } + + public String getPartitionKey() { + return partitionKey; } - + public User getUser() { return user; } + + public Device getDevice() { + return device; + } + + public void clear() { + this.locationPoints.clear(); + } } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/MapLibreMapStylesService.java b/src/main/java/com/dedicatedcode/reitti/service/MapLibreMapStylesService.java new file mode 100644 index 000000000..24ff7dcc1 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/MapLibreMapStylesService.java @@ -0,0 +1,522 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.dto.MapLibreStyleDefinition; +import com.dedicatedcode.reitti.model.map.MapStyleDataSource; +import com.dedicatedcode.reitti.model.map.UserMapStyle; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class MapLibreMapStylesService { + private static final Logger log = LoggerFactory.getLogger(MapLibreMapStylesService.class); + + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final ContextPathHolder contextPathHolder; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final boolean tileCachingEnabled; + + // Cache for original tile URLs: key = styleId + ":" + sourceId, value = original tile URL template + private final ConcurrentHashMap originalTileUrlCache = new ConcurrentHashMap<>(); + // Cache for original TileJSON URLs: key = styleId + ":" + sourceId + ":tilejson", value = original TileJSON URL + private final ConcurrentHashMap originalTileJsonUrlCache = new ConcurrentHashMap<>(); + + public MapLibreMapStylesService( + UserMapStyleJdbcService userMapStyleJdbcService, + ContextPathHolder contextPathHolder, + ObjectMapper objectMapper, + @Value("${reitti.ui.tiles.cache.url:}") String cacheUrl) { + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.contextPathHolder = contextPathHolder; + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + this.tileCachingEnabled = StringUtils.hasText(cacheUrl); + } + + @Cacheable("mapStyles") + public List getConfig(User user) { + List all = this.userMapStyleJdbcService.findAll(user); + List definitions = new ArrayList<>(); + for (UserMapStyle style : all) { + try { + definitions.add(buildStyleDefinition(style)); + } catch (Exception e) { + log.warn("Failed to build style definition for style [{}]: {}", style.id(), e.getMessage()); + } + } + return definitions; + } + + @Cacheable(value = "mapStyleJson", key = "#styleId + ':' + (#user != null ? #user.id : 'anon')") + public JsonNode getCompleteStyleJson(Long styleId, User user) { + try { + Optional styleOpt = userMapStyleJdbcService.findById(user, styleId); + if (styleOpt.isEmpty()) { + return null; + } + UserMapStyle style = styleOpt.get(); + return buildCustomStyleJson(style); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public String getOriginalTileUrl(Long styleId, String sourceId, User user) { + String cacheKey = styleId + ":" + sourceId; + // 1. Check tile URL cache + String cachedTileUrl = originalTileUrlCache.get(cacheKey); + if (cachedTileUrl != null) { + return cachedTileUrl; + } + // 2. Check TileJSON URL cache (if we have a TileJSON URL, fetch it and cache the tile URL) + String tileJsonCacheKey = cacheKey + ":tilejson"; + String cachedTileJsonUrl = originalTileJsonUrlCache.get(tileJsonCacheKey); + if (cachedTileJsonUrl != null) { + try { + String tileUrl = fetchTileUrlFromTileJson(cachedTileJsonUrl); + if (tileUrl != null) { + originalTileUrlCache.put(cacheKey, tileUrl); + return tileUrl; + } + } catch (Exception e) { + log.warn("Failed to fetch tile URL from cached TileJSON [{}]: {}", cachedTileJsonUrl, e.getMessage()); + } + } + // 3. Fallback: parse the style JSON (without proxying) to get the original URL + try { + Optional styleOpt = userMapStyleJdbcService.findById(user, styleId); + if (styleOpt.isEmpty()) { + return null; + } + UserMapStyle style = styleOpt.get(); + JsonNode originalStyle = buildCustomStyleJsonInternal(style, false); + if (originalStyle == null) { + return null; + } + JsonNode source = findSource(originalStyle, sourceId); + if (source == null) { + return null; + } + // Try "tiles" array first + JsonNode tiles = source.get("tiles"); + if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + String tileUrl = tileArray.get(0).asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + originalTileUrlCache.put(cacheKey, tileUrl); + return tileUrl; + } + } + // Try "url" (TileJSON) + String url = source.path("url").asText(""); + if (url.startsWith("http://") || url.startsWith("https://")) { + // Store the TileJSON URL and then fetch the tile URL + originalTileJsonUrlCache.put(tileJsonCacheKey, url); + String tileUrl = fetchTileUrlFromTileJson(url); + if (tileUrl != null) { + originalTileUrlCache.put(cacheKey, tileUrl); + return tileUrl; + } + return url; // fallback: return the TileJSON URL itself + } + return null; + } catch (Exception e) { + log.warn("Failed to get original tile URL for [{}/{}]: {}", styleId, sourceId, e.getMessage()); + return null; + } + } + + /** + * Returns the original TileJSON URL for a given style and source, if it exists. + * This method populates the internal caches if necessary. + */ + public String getOriginalTileJsonUrl(Long styleId, String sourceId, User user) { + String tileJsonCacheKey = styleId + ":" + sourceId + ":tilejson"; + // Check cache + String cached = originalTileJsonUrlCache.get(tileJsonCacheKey); + if (cached != null) { + return cached; + } + // Trigger getOriginalTileUrl which will fill the cache if appropriate + try { + getOriginalTileUrl(styleId, sourceId, user); + } catch (Exception e) { + log.debug("Could not populate tilejson cache: {}", e.getMessage()); + } + // Re-check + cached = originalTileJsonUrlCache.get(tileJsonCacheKey); + return cached; // may be null + } + + private String fetchTileUrlFromTileJson(String tileJsonUrl) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tileJsonUrl)) + .timeout(Duration.ofSeconds(20)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Failed to fetch TileJSON: HTTP " + response.statusCode()); + } + JsonNode tileJson = objectMapper.readTree(response.body()); + JsonNode tiles = tileJson.get("tiles"); + if (tiles instanceof ArrayNode tileArray && !tileArray.isEmpty()) { + String tileUrl = tileArray.get(0).asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + return tileUrl; + } + } + return null; + } + + private JsonNode buildCustomStyleJson(UserMapStyle style) throws IOException { + boolean shouldProxy = tileCachingEnabled + && style.dataSource() != null + && style.dataSource().proxyTiles(); + + return buildCustomStyleJsonInternal(style, shouldProxy); + } + + private JsonNode buildCustomStyleJsonInternal(UserMapStyle style, boolean shouldProxy) throws IOException { + Long styleId = style.id(); + + JsonNode styleJson; + if ("raster".equals(style.mapType())) { + styleJson = buildRasterStyleJson(style, shouldProxy, styleId); + } else if ("vector".equals(style.mapType())) { + styleJson = buildVectorStyleJson(style, shouldProxy, styleId); + } else { + return null; + } + + if (styleJson == null) { + return null; + } + return finalizeStyle((ObjectNode) styleJson, styleId, shouldProxy); + } + + private JsonNode buildRasterStyleJson(UserMapStyle style, boolean shouldProxy, Long styleId) { + MapStyleDataSource dataSource = style.dataSource(); + if (dataSource == null) { + return null; + } + + ObjectNode rasterStyle = objectMapper.createObjectNode(); + rasterStyle.put("version", 8); + rasterStyle.put("name", style.name() != null ? style.name() : "Raster"); + + String sourceId = dataSource.sourceId() != null ? dataSource.sourceId() : "raster-tiles"; + ObjectNode source = objectMapper.createObjectNode(); + source.put("type", "raster"); + source.put("tileSize", dataSource.tileSize() != null ? dataSource.tileSize() : 256); + if (StringUtils.hasText(dataSource.attribution())) { + source.put("attribution", dataSource.attribution()); + } + if (dataSource.minzoom() != null) { + source.put("minzoom", dataSource.minzoom()); + } + if (dataSource.maxzoom() != null) { + source.put("maxzoom", dataSource.maxzoom()); + } + + if (StringUtils.hasText(dataSource.tileJsonUrl())) { + String originalTileJsonUrl = dataSource.tileJsonUrl(); + if (shouldProxy) { + // Store original TileJSON URL in cache + String tileJsonCacheKey = styleId + ":" + sourceId + ":tilejson"; + originalTileJsonUrlCache.put(tileJsonCacheKey, originalTileJsonUrl); + source.put("url", proxyTileJsonUrl(styleId, sourceId)); + } else { + source.put("url", originalTileJsonUrl); + } + } else if (StringUtils.hasText(dataSource.tileUrlTemplate())) { + String originalTileUrl = dataSource.tileUrlTemplate(); + if (shouldProxy) { + // Store original tile URL in cache + String cacheKey = styleId + ":" + sourceId; + originalTileUrlCache.put(cacheKey, originalTileUrl); + String proxiedUrl = proxyTileUrl(styleId, sourceId, originalTileUrl); + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(proxiedUrl); + source.set("tiles", tiles); + } else { + ArrayNode tiles = objectMapper.createArrayNode(); + tiles.add(originalTileUrl); + source.set("tiles", tiles); + } + } else { + return null; + } + + ObjectNode sources = objectMapper.createObjectNode(); + sources.set(sourceId, source); + rasterStyle.set("sources", sources); + + ObjectNode layer = objectMapper.createObjectNode(); + layer.put("id", "raster-layer"); + layer.put("type", "raster"); + layer.put("source", sourceId); + + ArrayNode layers = objectMapper.createArrayNode(); + layers.add(layer); + rasterStyle.set("layers", layers); + + return rasterStyle; + } + + private JsonNode buildVectorStyleJson(UserMapStyle style, boolean shouldProxy, Long styleId) throws IOException { + JsonNode styleNode; + + if (StringUtils.hasText(style.styleJson())) { + styleNode = objectMapper.readTree(style.styleJson()); + } else if (StringUtils.hasText(style.styleUrl())) { + styleNode = fetchStyleJson(style.styleUrl()); + } else { + return null; + } + + if (shouldProxy && styleNode instanceof ObjectNode) { + rewriteTileUrlsInStyle((ObjectNode) styleNode, styleId); + } + + return styleNode; + } + + private JsonNode finalizeStyle(ObjectNode style, Long styleId, boolean proxyEnabled) { + + if (proxyEnabled) { + rewriteResourceUrls(style); + rewriteTileUrlsInStyle(style, styleId); + } + + return style; + } + + private void rewriteResourceUrls(ObjectNode style) { + if (style.get("glyphs") instanceof TextNode glyphsText && glyphsText.asText().startsWith("/")) { + style.set("glyphs", new TextNode(contextPathHolder.getContextPath() + glyphsText.asText())); + } + } + + private void rewriteTileUrlsInStyle(ObjectNode style, Long styleId) { + JsonNode sources = style.path("sources"); + if (!(sources instanceof ObjectNode sourcesObject)) { + return; + } + + for (Map.Entry entry : sourcesObject.properties()) { + String sourceId = entry.getKey(); + JsonNode source = entry.getValue(); + if (!(source instanceof ObjectNode sourceNode)) { + continue; + } + + // Rewrite "url" (TileJSON URL) + if (sourceNode.has("url")) { + String originalUrl = sourceNode.get("url").asText(""); + if (originalUrl.startsWith("http://") || originalUrl.startsWith("https://")) { + // Store original TileJSON URL in cache + String tileJsonCacheKey = styleId + ":" + sourceId + ":tilejson"; + originalTileJsonUrlCache.put(tileJsonCacheKey, originalUrl); + sourceNode.put("url", proxyTileJsonUrl(styleId, sourceId)); + } + } + + // Rewrite "tiles" array + if (sourceNode.has("tiles") && sourceNode.get("tiles") instanceof ArrayNode tiles) { + ArrayNode rewrittenTiles = objectMapper.createArrayNode(); + for (JsonNode tile : tiles) { + String tileUrl = tile.asText(""); + if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) { + // Store original tile URL in cache + String cacheKey = styleId + ":" + sourceId; + originalTileUrlCache.put(cacheKey, tileUrl); + rewrittenTiles.add(proxyTileUrl(styleId, sourceId, tileUrl)); + } else { + rewrittenTiles.add(tileUrl); + } + } + sourceNode.set("tiles", rewrittenTiles); + } + } + } + + private String proxyTileUrl(Long styleId, String sourceId, String originalUrl) { + // originalUrl is already stored in cache by the caller (rewriteTileUrlsInStyle or buildRasterStyleJson) + String ext = TileUrlUtils.extractTileExtension(originalUrl); + return contextPathHolder.getContextPath() + "/api/v1/tiles/styles/" + styleId + "/" + sourceId + "/{z}/{x}/{y}." + ext; + } + + private String proxyTileJsonUrl(Long styleId, String sourceId) { + return contextPathHolder.getContextPath() + "/api/v1/tiles/styles/" + styleId + "/" + sourceId + "/tilejson.json"; + } + + private JsonNode fetchStyleJson(String styleUrl) throws IOException { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(styleUrl)) + .timeout(Duration.ofSeconds(20)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Failed to fetch style JSON: HTTP " + response.statusCode()); + } + return objectMapper.readTree(response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while fetching style JSON", e); + } + } + + private MapLibreStyleDefinition buildStyleDefinition(UserMapStyle style) { + Long styleId = style.id(); + String contextPath = contextPathHolder.getContextPath(); + + return new MapLibreStyleDefinition( + styleId, + style.name(), + style.mapType(), + "url", + contextPath + "/api/v1/tiles/styles/" + styleId + "/style.json", + detectCapabilities(style)); + } + + private JsonNode findSource(JsonNode style, String sourceId) { + JsonNode sources = style.path("sources"); + if (sources instanceof ObjectNode sourcesObject) { + return sourcesObject.get(sourceId); + } + return null; + } + + private Map detectCapabilities(UserMapStyle style) { + if ("raster".equals(style.mapType())) { + return Map.of(); + } + + // Obtain the raw style JSON either from inline JSON or from style URL. + JsonNode styleJson = getStyleJsonForDetection(style); + if (!(styleJson instanceof ObjectNode root)) { + return Map.of(); + } + + Map caps = new HashMap<>(); + + // 1. Terrain source (raster-dem) + JsonNode sources = root.path("sources"); + if (sources instanceof ObjectNode sourcesObj) { + sourcesObj.properties().forEach(entry -> { + JsonNode source = entry.getValue(); + if (source.isObject() && "raster-dem".equals(source.path("type").asText())) { + caps.put("terrainSourceId", entry.getKey()); + } + }); + } + + // 2. Hillshade layer (type = "hillshade") + JsonNode layers = root.path("layers"); + if (layers instanceof ArrayNode layerArray) { + for (JsonNode layer : layerArray) { + if ("hillshade".equals(layer.path("type").asText())) { + caps.put("hillshadeLayerId", layer.path("id").asText()); + break; + } + } + + // 3. Satellite layer (raster source with known satellite patterns) + String satelliteLayerId = detectSatelliteLayer(root, layerArray); + if (satelliteLayerId != null) { + caps.put("satelliteLayerId", satelliteLayerId); + } + + // 4. Building 3D layers (fill-extrusion with building in source-layer or id) + List building3dIds = new ArrayList<>(); + for (JsonNode layer : layerArray) { + if (!"fill-extrusion".equals(layer.path("type").asText())) { + continue; + } + String id = layer.path("id").asText(""); + String sourceLayer = layer.path("source-layer").asText(""); + if (id.toLowerCase().contains("building") || sourceLayer.toLowerCase().contains("building")) { + building3dIds.add(id); + } + } + if (!building3dIds.isEmpty()) { + caps.put("building3dLayerIds", building3dIds); + } + } + + return caps; + } + + private String detectSatelliteLayer(ObjectNode style, ArrayNode layers) { + ObjectNode sources = style.path("sources").isObject() ? (ObjectNode) style.path("sources") : null; + if (sources == null) return null; + + for (JsonNode layer : layers) { + if (!"raster".equals(layer.path("type").asText())) continue; + String sourceId = layer.path("source").asText(""); + JsonNode source = sources.get(sourceId); + if (source == null || !source.isObject()) continue; + + // Check source URL for known satellite imagery endpoints + String sourceUrl = source.path("url").asText(""); + JsonNode tiles = source.get("tiles"); + String firstTileUrl = (tiles instanceof ArrayNode && !tiles.isEmpty()) ? tiles.get(0).asText("") : ""; + String combinedUrl = sourceUrl + " " + firstTileUrl; + boolean isSatelliteUrl = combinedUrl.contains("arcgisonline") + || combinedUrl.contains("world_imagery") + || combinedUrl.contains("sentinel") + || combinedUrl.contains("planet"); + + boolean nameContainsSatellite = layer.path("id").asText("").toLowerCase().contains("satellite") + || sourceId.toLowerCase().contains("satellite"); + + if (isSatelliteUrl || nameContainsSatellite) { + return layer.path("id").asText(); + } + } + return null; + } + + private JsonNode getStyleJsonForDetection(UserMapStyle style) { + try { + if (style.styleJson() != null && !style.styleJson().isBlank()) { + return objectMapper.readTree(style.styleJson()); + } + if (style.styleUrl() != null && !style.styleUrl().isBlank()) { + return fetchStyleJson(style.styleUrl()); + } + } catch (Exception e) { + log.warn("Could not parse style JSON for capability detection [{}]: {}", style.id(), e.getMessage()); + } + return null; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java b/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java index 1d3fca008..7bffe7a91 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/MemoryBlockGenerationService.java @@ -90,7 +90,7 @@ public List generate(User user, Memory memory, ZoneId timeZone) log.info("Found {} visits after filtering (accommodation: {})", filteredVisits.size(), accommodation.map(a -> a.getPlace().getName()).orElse("none")); // Step 3: Scoring & Identifying "Interesting" Visits - List scoredVisits = scoreVisits(filteredVisits, accommodation.orElse(null)); + List scoredVisits = new ArrayList<>(scoreVisits(filteredVisits, accommodation.orElse(null))); // Sort by score descending scoredVisits.sort(Comparator.comparingDouble(ScoredVisit::score).reversed()); @@ -298,7 +298,7 @@ private String generateIntroductionText(List clusters, ProcessedVi SignificantPlace accommodationPlace = accommodation.getPlace(); String country; if (accommodationPlace.getCountryCode() != null) { - country = i18n.translateWithDefault("country." + accommodationPlace.getCountryCode() + ".label", accommodation.getPlace().getCountryCode().toLowerCase()); + country = i18n.translate("country." + accommodationPlace.getCountryCode() + ".label"); } else { country = i18n.translate("country.unknown.label"); } @@ -352,7 +352,7 @@ private List filterVisits(List visits, Processed return visit.getDurationSeconds() >= MIN_VISIT_DURATION_SECONDS; }) - .collect(Collectors.toList()); + .toList(); } /** @@ -372,11 +372,11 @@ private List scoreVisits(List visits, ProcessedVisi return visits.stream() .map(visit -> { double score = 0.0; - + // Duration score (normalized 0-1) double durationScore = (double) visit.getDurationSeconds() / maxDuration; score += WEIGHT_DURATION * durationScore; - + // Distance from accommodation score if (accommodation != null) { double distance = GeoUtils.distanceInMeters( @@ -389,19 +389,19 @@ private List scoreVisits(List visits, ProcessedVisi double distanceScore = Math.min(distance / 50000.0, 1.0); score += WEIGHT_DISTANCE * distanceScore; } - + // Category score double categoryScore = getCategoryWeight(visit.getPlace().getType()); score += WEIGHT_CATEGORY * categoryScore; - + // Novelty score (inverse of visit count, normalized) long visitCount = visitCounts.get(visit.getPlace().getId()); double noveltyScore = 1.0 / visitCount; score += WEIGHT_NOVELTY * noveltyScore; - + return new ScoredVisit(visit, score); }) - .collect(Collectors.toList()); + .toList(); } /** @@ -483,33 +483,33 @@ private record ScoredVisit(ProcessedVisit visit, double score) { */ private static class VisitCluster { private final List visits = new ArrayList<>(); - + public void addVisit(ScoredVisit visit) { visits.add(visit); } - + public List getVisits() { return visits; } - + public ScoredVisit getHighestScoredVisit() { return visits.stream() .max(Comparator.comparingDouble(ScoredVisit::score)) - .orElse(null); + .orElse(null); } - + public Instant getStartTime() { return visits.stream() .map(sv -> sv.visit().getStartTime()) - .min(Instant::compareTo) - .orElse(null); + .min(Instant::compareTo) + .orElse(null); } - + public Instant getEndTime() { return visits.stream() .map(sv -> sv.visit().getEndTime()) - .max(Instant::compareTo) - .orElse(null); + .max(Instant::compareTo) + .orElse(null); } } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java b/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java deleted file mode 100644 index cc2e03804..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/MessageDispatcherService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import com.dedicatedcode.reitti.event.SSEEvent; -import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent; -import com.dedicatedcode.reitti.event.TriggerProcessingEvent; -import com.dedicatedcode.reitti.repository.UserJdbcService; -import com.dedicatedcode.reitti.service.geocoding.ReverseGeocodingListener; -import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; -import com.dedicatedcode.reitti.service.queue.RedisQueueListener; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -@Service -public class MessageDispatcherService { - - private static final Logger logger = LoggerFactory.getLogger(MessageDispatcherService.class); - public static final String PLACE_CREATED_QUEUE = "reitti.place.created.v2"; - public static final String USER_EVENT_QUEUE = "reitti.user.events.v2"; - public static final String TRIGGER_PROCESSING_QUEUE = "reitti.processing.v2"; - - private final ReverseGeocodingListener reverseGeocodingListener; - private final ProcessingPipelineTrigger processingPipelineTrigger; - private final UserSseEmitterService userSseEmitterService; - private final UserJdbcService userJdbcService; - private final VisitDetectionPreviewService visitDetectionPreviewService; - - @Autowired - public MessageDispatcherService(ReverseGeocodingListener reverseGeocodingListener, - ProcessingPipelineTrigger processingPipelineTrigger, - UserSseEmitterService userSseEmitterService, - UserJdbcService userJdbcService, - VisitDetectionPreviewService visitDetectionPreviewService) { - this.reverseGeocodingListener = reverseGeocodingListener; - this.processingPipelineTrigger = processingPipelineTrigger; - this.userSseEmitterService = userSseEmitterService; - this.userJdbcService = userJdbcService; - this.visitDetectionPreviewService = visitDetectionPreviewService; - } - - @RedisQueueListener(value = PLACE_CREATED_QUEUE, deadLetterQueue = "reitti.dlq.v2") - public void handleSignificantPlaceCreated(SignificantPlaceCreatedEvent event) { - logger.info("Dispatching SignificantPlaceCreatedEvent: {}", event); - reverseGeocodingListener.handleSignificantPlaceCreated(event); - visitDetectionPreviewService.updatePreviewStatus(event.previewId()); - } - - @RedisQueueListener(value = USER_EVENT_QUEUE) - public void handleUserNotificationEvent(SSEEvent event) { - logger.trace("Dispatching SSEEvent: {}", event); - this.userJdbcService.findById(event.getUserId()).ifPresentOrElse(user -> this.userSseEmitterService.sendEventToUser(user, event), () -> logger.warn("User not found for user: {}", event.getUserId())); - } - - @RedisQueueListener(value = TRIGGER_PROCESSING_QUEUE) - public void handleTriggerProcessingEvent(TriggerProcessingEvent event) { - logger.info("Dispatching TriggerProcessingEvent {}", event); - processingPipelineTrigger.handle(event, false); - visitDetectionPreviewService.updatePreviewStatus(event.getPreviewId()); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/MetadataOverrideService.java b/src/main/java/com/dedicatedcode/reitti/service/MetadataOverrideService.java new file mode 100644 index 000000000..b26ad3ff5 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/MetadataOverrideService.java @@ -0,0 +1,79 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.model.geo.ProcessedVisit; +import com.dedicatedcode.reitti.model.geo.Trip; +import com.dedicatedcode.reitti.model.metadata.MemoryMetadata; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.MetadataOverrideJdbcService; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.TripJdbcService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +public class MetadataOverrideService { + + private final MetadataOverrideJdbcService overrideJdbcService; + private final TripJdbcService tripJdbcService; + private final ProcessedVisitJdbcService processedVisitJdbcService; + + public MetadataOverrideService(MetadataOverrideJdbcService overrideJdbcService, + TripJdbcService tripJdbcService, + ProcessedVisitJdbcService processedVisitJdbcService) { + this.overrideJdbcService = overrideJdbcService; + this.tripJdbcService = tripJdbcService; + this.processedVisitJdbcService = processedVisitJdbcService; + } + + /** + * Requirement 1: User saves metadata from the UI form. + * Dual-writes to the active cached entity and the persistent vault table. + */ + @Transactional + public void saveTripMetadata(User user, Trip currentTrip, MemoryMetadata dto) { + try { + MemoryMetadata override = this.overrideJdbcService + .findBestOverlappingOverride(user, currentTrip.getStartTime(), currentTrip.getEndTime()).orElseGet(() -> { + MemoryMetadata metadata = new MemoryMetadata(currentTrip.getStartTime(), currentTrip.getEndTime()); + this.overrideJdbcService.insertOverride(user, "TRIP", metadata); + return metadata; + }); + override.setProperties(dto.getProperties()); + this.overrideJdbcService.updateOverridePayload(user, override); + this.tripJdbcService.update(currentTrip.withMetadata(override.getProperties())); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize and save metadata", e); + } + } + + @Transactional + public void saveVisitMetadata(User user, ProcessedVisit currentVisit, MemoryMetadata dto) { + try { + MemoryMetadata override = this.overrideJdbcService + .findBestOverlappingOverride(user, currentVisit.getStartTime(), currentVisit.getEndTime()).orElseGet(() -> { + MemoryMetadata metadata = new MemoryMetadata(currentVisit.getStartTime(), currentVisit.getEndTime()); + this.overrideJdbcService.insertOverride(user, "VISIT", metadata); + return metadata; + }); + override.setProperties(dto.getProperties()); + this.overrideJdbcService.updateOverridePayload(user, override); + this.processedVisitJdbcService.update(currentVisit.withMetadata(override.getProperties())); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize and save metadata", e); + } + } + + @Transactional(readOnly = true) + public Optional findOverlappingMetadata(User user, Instant startTime, Instant endTime) { + return overrideJdbcService + .findBestOverlappingOverride(user, startTime, endTime); + } + + public List loadSuggestions(User user, String field, String query) { + return overrideJdbcService.findDistinctSuggestions(user, field, query); + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java b/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java deleted file mode 100644 index 84a6dcea5..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.dedicatedcode.reitti.service; - -import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; -import com.dedicatedcode.reitti.service.queue.QueueStatistics; -import com.dedicatedcode.reitti.service.queue.RedisQueueService; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Service -public class QueueStatsService { - - public static final String STAY_DETECTION_QUEUE = "reitti.visit.detection.v2"; - public static final String LOCATION_DATA_QUEUE = "reitti.location.data.v2"; - private final RedisQueueService redisQueueService; - private final MessageSource messageSource; - private final ProcessingPipelineTrigger processingPipelineTrigger; - private final DefaultImportProcessor defaultImportProcessor; - private static final int LOOKBACK_HOURS = 24; - private static final long DEFAULT_PROCESSING_TIME = 2000; - - private final List QUEUES = List.of( - "reitti.place.created.v2", - "reitti.user.events.v2" - ); - - private final Map> processingHistory = new ConcurrentHashMap<>(); - - private final Map previousMessageCounts = new ConcurrentHashMap<>(); - - public QueueStatsService(RedisQueueService redisQueueService, - MessageSource messageSource, - ProcessingPipelineTrigger processingPipelineTrigger, - DefaultImportProcessor defaultImportProcessor) { - this.redisQueueService = redisQueueService; - this.messageSource = messageSource; - this.processingPipelineTrigger = processingPipelineTrigger; - this.defaultImportProcessor = defaultImportProcessor; - QUEUES.forEach(queue -> { - processingHistory.put(queue, new ArrayList<>()); - previousMessageCounts.put(queue, 0); - }); - processingHistory.put(STAY_DETECTION_QUEUE, new ArrayList<>()); - previousMessageCounts.put(STAY_DETECTION_QUEUE, 0); - processingHistory.put(LOCATION_DATA_QUEUE, new ArrayList<>()); - previousMessageCounts.put(LOCATION_DATA_QUEUE, 0); - - } - - public List getQueueStats() { - List list = QUEUES.stream().map(this::getQueueStats).toList(); - List result = new ArrayList<>(list); - result.add(0, getQueueStats(LOCATION_DATA_QUEUE)); - result.add(1, getQueueStats(STAY_DETECTION_QUEUE)); - return result; - } - - private QueueStats getQueueStats(String name) { - int currentMessageCount; - if (name.equals(STAY_DETECTION_QUEUE)) { - currentMessageCount = this.processingPipelineTrigger.getPendingCount(); - updatingStayDetectionQueue(currentMessageCount); - }else if (name.equals(LOCATION_DATA_QUEUE)) { - currentMessageCount = this.defaultImportProcessor.getPendingTaskCount(); - updatingLocationDataQueue(currentMessageCount); - } else { - currentMessageCount = getMessageCount(name); - updateProcessingHistory(name, currentMessageCount); - } - - long avgProcessingTime = calculateAverageProcessingTime(name); - long estimatedTime = currentMessageCount * avgProcessingTime; - - String displayName = getLocalizedDisplayName(name); - String description = getLocalizedDescription(name); - - return new QueueStats(name, displayName, description, currentMessageCount, formatProcessingTime(estimatedTime), calculateProgress(name, currentMessageCount)); - } - - private void updateProcessingHistory(String queueName, int currentMessageCount) { - Integer previousCount = previousMessageCounts.get(queueName); - - if (previousCount != null && currentMessageCount < previousCount) { - long processingTimePerMessage = estimateProcessingTimePerMessage(queueName); - List history = processingHistory.get(queueName); - LocalDateTime now = LocalDateTime.now(); - QueueStatistics scheduledMessageCount = redisQueueService.getQueueStats(queueName); - history.add(new ProcessingRecord(now, scheduledMessageCount.currentProcessingLength() + scheduledMessageCount.currentQueueLength(), processingTimePerMessage)); - cleanupOldRecords(history, now); - } - - previousMessageCounts.put(queueName, currentMessageCount); - } - - private void updatingStayDetectionQueue(int currentMessageCount) { - Integer previousCount = previousMessageCounts.get(STAY_DETECTION_QUEUE); - - if (previousCount != null && currentMessageCount < previousCount) { - long processingTimePerMessage = estimateProcessingTimePerMessage(STAY_DETECTION_QUEUE); - List history = processingHistory.get(STAY_DETECTION_QUEUE); - LocalDateTime now = LocalDateTime.now(); - history.add(new ProcessingRecord(now, this.processingPipelineTrigger.getPendingCount(), processingTimePerMessage)); - cleanupOldRecords(history, now); - } - - previousMessageCounts.put(STAY_DETECTION_QUEUE, currentMessageCount); - } - - private void updatingLocationDataQueue(int currentMessageCount) { - Integer previousCount = previousMessageCounts.get(LOCATION_DATA_QUEUE); - - if (previousCount != null && currentMessageCount < previousCount) { - long processingTimePerMessage = estimateProcessingTimePerMessage(LOCATION_DATA_QUEUE); - List history = processingHistory.get(LOCATION_DATA_QUEUE); - LocalDateTime now = LocalDateTime.now(); - history.add(new ProcessingRecord(now, this.defaultImportProcessor.getPendingTaskCount(), processingTimePerMessage)); - cleanupOldRecords(history, now); - } - - previousMessageCounts.put(LOCATION_DATA_QUEUE, currentMessageCount); - } - - private long estimateProcessingTimePerMessage(String queueName) { - List history = processingHistory.get(queueName); - - if (history.isEmpty()) { - return DEFAULT_PROCESSING_TIME; - } - - return calculateAverageFromHistory(history); - } - - private long calculateAverageProcessingTime(String queueName) { - List history = processingHistory.get(queueName); - - if (history.isEmpty()) { - return DEFAULT_PROCESSING_TIME; - } - - return calculateAverageFromHistory(history); - } - - private long calculateAverageFromHistory(List history) { - if (history.isEmpty()) { - return DEFAULT_PROCESSING_TIME; - } - - return (long) history.stream() - .mapToLong(record -> record.processingTimeMs) - .average() - .orElse(DEFAULT_PROCESSING_TIME); - } - - private void cleanupOldRecords(List history, LocalDateTime now) { - LocalDateTime cutoff = now.minusHours(LOOKBACK_HOURS); - history.removeIf(record -> record.timestamp.isBefore(cutoff)); - } - - private int getMessageCount(String queueName) { - long scheduledMessageCount = redisQueueService.getQueueStats(queueName).currentQueueLength(); - long pendingMessageCount = redisQueueService.getQueueStats(queueName).pendingCount(); - return (int) (scheduledMessageCount + pendingMessageCount); - } - - private String formatProcessingTime(long milliseconds) { - if (milliseconds < 60000) { - return (milliseconds / 1000) + " sec"; - } else if (milliseconds < 3600000) { - return (milliseconds / 60000) + " min"; - } else { - long hours = milliseconds / 3600000; - long minutes = (milliseconds % 3600000) / 60000; - return hours + " hr " + minutes + " min"; - } - } - - private int calculateProgress(String queueName, int currentMessageCount) { - if (currentMessageCount == 0) return 100; // No messages = fully processed - - List history = processingHistory.get(queueName); - if (history.isEmpty()) { - if (currentMessageCount <= 5) return 80; - if (currentMessageCount <= 20) return 60; - if (currentMessageCount <= 100) return 40; - return 20; - } - - // Calculate processing trend over recent history - Integer previousCount = previousMessageCounts.get(queueName); - if (previousCount != null && previousCount > currentMessageCount) { - // Messages are being processed - higher progress - int processedRecently = previousCount - currentMessageCount; - double processingRate = Math.min(100, (processedRecently / (double) Math.max(1, previousCount)) * 100); - return Math.max(50, (int) (50 + processingRate / 2)); // 50-100% range when actively processing - } - - // Queue is stable or growing - lower progress based on size - if (currentMessageCount <= 10) return 70; - if (currentMessageCount <= 50) return 50; - if (currentMessageCount <= 200) return 30; - return 10; - } - - - private String getLocalizedDisplayName(String queueName) { - String key = getMessageKeyForQueue(queueName, "name"); - return messageSource.getMessage(key, null, queueName, LocaleContextHolder.getLocale()); - } - - private String getLocalizedDescription(String queueName) { - String key = getMessageKeyForQueue(queueName, "description"); - return messageSource.getMessage(key, null, "Processing " + queueName, LocaleContextHolder.getLocale()); - } - - private String getMessageKeyForQueue(String queueName, String suffix) { - return switch (queueName) { - case "reitti.place.created.v2" -> "queue.significant.place." + suffix; - case "reitti.user.events.v2" -> "queue.user.event." + suffix; - case STAY_DETECTION_QUEUE -> "queue.stay.detection." + suffix; - case LOCATION_DATA_QUEUE -> "queue.location.data." + suffix; - default -> "queue.unknown." + suffix; - }; - } - - private record ProcessingRecord(LocalDateTime timestamp, long numberOfMessages, long processingTimeMs) { } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/StatisticsService.java b/src/main/java/com/dedicatedcode/reitti/service/StatisticsService.java index 5d8b27720..6e82a2367 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/StatisticsService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/StatisticsService.java @@ -33,23 +33,6 @@ public StatisticsService(TripJdbcService tripJdbcService, this.i18nService = i18nService; } - private TransportStatistic mapTransportStatistics(Object[] row) { - String transportMode = (String) row[0]; - Double totalDistanceMeters = (Double) row[1]; - Long durationInSeconds = (Long) row[2]; - Long tripCount = (Long) row[3]; - - double totalDistanceKm = totalDistanceMeters / 1000.0; - double totalDurationHours = durationInSeconds / 3600.0; - - return new TransportStatistic( - transportMode != null ? transportMode : i18nService.translate("timeline.transport.UNKNOWN.label"), - totalDistanceKm, - totalDurationHours, - tripCount.intValue() - ); - } - public List getAvailableYears(User user) { return rawLocationPointJdbcService.findDistinctYearsByUser(user); } @@ -262,4 +245,21 @@ public List getMonthTransportStatistics(User user, int year, Instant endOfMonth = LocalDate.of(year, month, 1).plusMonths(1).minusDays(1).atTime(23, 59, 59).atZone(ZoneId.systemDefault()).toInstant(); return getTransportStatistics(user, startOfMonth, endOfMonth); } + + private TransportStatistic mapTransportStatistics(Object[] row) { + String transportMode = (String) row[0]; + Double totalDistanceMeters = (Double) row[1]; + Long durationInSeconds = (Long) row[2]; + Long tripCount = (Long) row[3]; + + double totalDistanceKm = totalDistanceMeters / 1000.0; + double totalDurationHours = durationInSeconds / 3600.0; + + return new TransportStatistic( + transportMode != null ? transportMode : i18nService.translate("timeline.transport.UNKNOWN.label"), + totalDistanceKm, + totalDurationHours, + tripCount.intValue() + ); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/StreamingRawLocationPointJdbcService.java b/src/main/java/com/dedicatedcode/reitti/service/StreamingRawLocationPointJdbcService.java index 85dededb6..2cfb34940 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/StreamingRawLocationPointJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/StreamingRawLocationPointJdbcService.java @@ -1,9 +1,12 @@ package com.dedicatedcode.reitti.service; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementSetter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; @@ -15,7 +18,6 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.ZoneId; -import java.time.ZoneOffset; import java.util.HashMap; import java.util.Map; @@ -23,6 +25,7 @@ public class StreamingRawLocationPointJdbcService { private static final Logger log = LoggerFactory.getLogger(StreamingRawLocationPointJdbcService.class); + private static final int POINTS_PER_BATCH = 8192; private final JdbcTemplate jdbcTemplate; private final GeoLocationTimezoneService timezoneService; @@ -32,7 +35,7 @@ public StreamingRawLocationPointJdbcService(JdbcTemplate jdbcTemplate, GeoLocati } @Transactional(readOnly = true) - public void streamPoints(Long userId, Instant start, Instant end, ResponseBodyEmitter emitter) { + public void streamPoints(User user, Instant start, Instant end, ResponseBodyEmitter emitter) { String sql = """ SELECT ST_X(geom) as lng, @@ -42,21 +45,46 @@ public void streamPoints(Long userId, Instant start, Instant end, ResponseBodyEm FROM raw_location_points WHERE user_id = ? AND timestamp >= ? AND timestamp < ? - AND invalid = false ORDER BY timestamp """; - int pointsPerBatch = 8192; - ByteBuffer buffer = ByteBuffer.allocate(pointsPerBatch * 20); - buffer.order(ByteOrder.LITTLE_ENDIAN); // Essential for JS Float32Array - Map timezoneCache = new HashMap<>(); - jdbcTemplate.query(sql, ps -> { - // Important for streaming - ps.setLong(1, userId); + streamSql(emitter, sql, ps -> { + ps.setLong(1, user.getId()); ps.setTimestamp(2, Timestamp.from(start)); ps.setTimestamp(3, Timestamp.from(end)); - ps.setFetchSize(pointsPerBatch); - }, rs -> { + ps.setFetchSize(POINTS_PER_BATCH); + }); + } + + @Transactional(readOnly = true) + public void streamPoints(User user, Device device, Instant start, Instant end, ResponseBodyEmitter emitter) { + String sql = """ + SELECT + ST_X(geom) as lng, + ST_Y(geom) as lat, + elevation_meters as alt, + EXTRACT(EPOCH FROM timestamp) as ts + FROM raw_source_points + WHERE user_id = ? + AND device_id = ? + AND timestamp >= ? AND timestamp < ? + ORDER BY timestamp + """; + + streamSql(emitter, sql, ps -> { + ps.setLong(1, user.getId()); + ps.setLong(2, device.id()); + ps.setTimestamp(3, Timestamp.from(start)); + ps.setTimestamp(4, Timestamp.from(end)); + ps.setFetchSize(POINTS_PER_BATCH); + }); + } + private void streamSql(ResponseBodyEmitter emitter, String sql, PreparedStatementSetter ps) { + ByteBuffer buffer = ByteBuffer.allocate(POINTS_PER_BATCH * 20); + buffer.order(ByteOrder.LITTLE_ENDIAN); + + Map timezoneCache = new HashMap<>(); + jdbcTemplate.query(sql, ps, rs -> { float lat = rs.getFloat("lat"); float lng = rs.getFloat("lng"); float ts = rs.getFloat("ts"); diff --git a/src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java b/src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java new file mode 100644 index 000000000..18fd360ec --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/TileUrlUtils.java @@ -0,0 +1,39 @@ +package com.dedicatedcode.reitti.service; + +public final class TileUrlUtils { + private TileUrlUtils() { + } + + public static String extractTileExtension(String url) { + String path = url.split("\\?", 2)[0]; + int placeholderIndex = path.indexOf("{y}"); + if (placeholderIndex < 0) { + return "pbf"; + } + + int extensionStart = placeholderIndex + 3; + while (extensionStart < path.length() && path.charAt(extensionStart) == '{') { + int tokenEnd = path.indexOf('}', extensionStart); + if (tokenEnd < 0) { + break; + } + extensionStart = tokenEnd + 1; + } + if (path.startsWith("@2x", extensionStart)) { + extensionStart += 3; + } + if (extensionStart >= path.length() || path.charAt(extensionStart) != '.') { + return "pbf"; + } + + int extensionEnd = extensionStart + 1; + while (extensionEnd < path.length() && Character.isLetterOrDigit(path.charAt(extensionEnd))) { + extensionEnd++; + } + + if (extensionEnd == extensionStart + 1) { + return "pbf"; + } + return path.substring(extensionStart + 1, extensionEnd); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java b/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java index 9ca3b32a1..091418d55 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java +++ b/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java @@ -6,6 +6,7 @@ import org.springframework.util.StringUtils; @Service +@Deprecated(forRemoval = true) public class TilesCustomizationProvider { private final UserSettingsDTO.TilesCustomizationDTO tilesConfiguration; @@ -14,7 +15,8 @@ public TilesCustomizationProvider( @Value("${reitti.ui.tiles.default.service}") String defaultService, @Value("${reitti.ui.tiles.default.attribution}") String defaultAttribution, @Value("${reitti.ui.tiles.custom.service:}") String customService, - @Value("${reitti.ui.tiles.custom.attribution:}") String customAttribution, ContextPathHolder contextPathHolder) { + @Value("${reitti.ui.tiles.custom.attribution:}") String customAttribution, + ContextPathHolder contextPathHolder) { String serviceUrl; if (StringUtils.hasText(customService)) { serviceUrl = customService; diff --git a/src/main/java/com/dedicatedcode/reitti/service/TimeUtil.java b/src/main/java/com/dedicatedcode/reitti/service/TimeUtil.java index 5f3552779..b960d9dbc 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/TimeUtil.java +++ b/src/main/java/com/dedicatedcode/reitti/service/TimeUtil.java @@ -1,8 +1,12 @@ package com.dedicatedcode.reitti.service; +import com.dedicatedcode.reitti.model.security.UserSettings; + import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; public class TimeUtil { public static LocalDateTime adjustInstant(Instant instant, ZoneId zoneId) { @@ -12,4 +16,34 @@ public static LocalDateTime adjustInstant(Instant instant, ZoneId zoneId) { return instant.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId).toLocalDateTime(); } } + + public static String formatTimeRange(Instant startTime, Instant endTime, ZoneId startTimezone, ZoneId endTimezone, LocalDate selectedDate, UserSettings userSettings) { + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("MMM d " + userSettings.getTimeMode().getPattern()); + + LocalDate startDate = startTime.atZone(startTimezone).toLocalDate(); + LocalDate endDate = endTime.atZone(endTimezone).toLocalDate(); + LocalDate selectedDateInStartTimezone = selectedDate.atTime(10,0).atZone(startTimezone).toLocalDate(); + LocalDate selectedDateInEndTimezone = selectedDate.atTime(10,0).atZone(endTimezone).toLocalDate(); + String start, end; + + // If start time is not on the selected date, show date + time + if (!startDate.equals(selectedDateInStartTimezone)) { + start = startTime.atZone(startTimezone).format(dateTimeFormatter); + } else { + start = userSettings.getTimeMode().format(startTime, startTimezone); + } + + // If end time is not on the selected date, show date + time + if (!endDate.equals(selectedDateInEndTimezone)) { + end = endTime.atZone(endTimezone).format(dateTimeFormatter); + } else { + end = userSettings.getTimeMode().format(endTime, endTimezone); + } + + return start + " - " + end; + } + + public static String formatTimeRange(Instant startTime, Instant endTime, ZoneId timezone, LocalDate selectedDate, UserSettings userSettings) { + return formatTimeRange(startTime, endTime, timezone, timezone, selectedDate, userSettings); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java b/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java index 2710a1745..a1746ddf2 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/TimelineService.java @@ -1,6 +1,6 @@ package com.dedicatedcode.reitti.service; -import com.dedicatedcode.reitti.dto.TimelineEntry; +import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry; import com.dedicatedcode.reitti.model.UnitSystem; import com.dedicatedcode.reitti.model.geo.ProcessedVisit; import com.dedicatedcode.reitti.model.geo.SignificantPlace; @@ -16,7 +16,6 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -46,21 +45,21 @@ public TimelineService(ProcessedVisitJdbcService processedVisitJdbcService, this.i18n = i18n; } - public List buildTimelineEntries(User user, String previewId, ZoneId userTimeZone, LocalDate selectedDate, Instant startOfDay, Instant endOfDay) { + public List buildTimelineEntries(User user, String previewId, ZoneId userTimeZone, LocalDate selectedDate, Instant startOfDay, Instant endOfDay, boolean ownData) { List processedVisits = previewProcessedVisitJdbcService.findByUserAndTimeOverlap(user, previewId, startOfDay, endOfDay); List trips = previewTripJdbcService.findByUserAndTimeOverlap(user, previewId, startOfDay, endOfDay); UserSettings userSettings = userSettingsJdbcService.findByUserId(user.getId()) .orElse(UserSettings.defaultSettings(user.getId())); try { - return buildTimelineEntries(processedVisits, trips, userTimeZone, selectedDate, userSettings); + return buildTimelineEntries(processedVisits, trips, userTimeZone, selectedDate, userSettings, ownData); } catch (JsonProcessingException e) { log.error("Unable to build timeline entries.", e); return Collections.emptyList(); } } - public List buildTimelineEntries(User user, ZoneId userTimeZone, LocalDate selectedDate, Instant startOfDay, Instant endOfDay) { + public List buildTimelineEntries(User user, ZoneId userTimeZone, LocalDate selectedDate, Instant startOfDay, Instant endOfDay, boolean ownData) { List processedVisits = processedVisitJdbcService.findByUserAndTimeOverlap(user, startOfDay, endOfDay); List trips = tripJdbcService.findByUserAndTimeOverlap(user, startOfDay, endOfDay); @@ -68,7 +67,7 @@ public List buildTimelineEntries(User user, ZoneId userTimeZone, UserSettings userSettings = userSettingsJdbcService.findByUserId(user.getId()) .orElse(UserSettings.defaultSettings(user.getId())); try { - return buildTimelineEntries(processedVisits, trips, userTimeZone, selectedDate, userSettings); + return buildTimelineEntries(processedVisits, trips, userTimeZone, selectedDate, userSettings, ownData); } catch (JsonProcessingException e) { log.error("Unable to build timeline entries.", e); return Collections.emptyList(); @@ -78,41 +77,43 @@ public List buildTimelineEntries(User user, ZoneId userTimeZone, /** * Build timeline entries from processed visits and trips */ - private List buildTimelineEntries(List processedVisits, List trips, ZoneId timezone, LocalDate selectedDate, UserSettings userSettings) throws JsonProcessingException { - List entries = new ArrayList<>(); + private List buildTimelineEntries(List processedVisits, List trips, ZoneId timezone, LocalDate selectedDate, UserSettings userSettings, boolean ownData) throws JsonProcessingException { + List entries = new ArrayList<>(); for (ProcessedVisit visit : processedVisits) { SignificantPlace place = visit.getPlace(); if (place != null) { - TimelineEntry entry = new TimelineEntry(); + SingleTimelineEntry entry = new SingleTimelineEntry(); entry.setId("visit-" + visit.getId()); entry.setResourceId(visit.getId()); - entry.setType(TimelineEntry.Type.VISIT); + entry.setType(SingleTimelineEntry.Type.VISIT); entry.setPlace(place); entry.setStartTime(visit.getStartTime()); entry.setStartTimezone(visit.getPlace().getTimezone()); entry.setEndTime(visit.getEndTime()); entry.setEndTimezone(visit.getPlace().getTimezone()); - entry.setFormattedTimeRange(formatTimeRange(visit.getStartTime(), visit.getEndTime(), timezone, selectedDate, userSettings)); - entry.setFormattedLocalTimeRange(formatTimeRange(visit.getStartTime(), visit.getEndTime(), visit.getPlace().getTimezone(), selectedDate, userSettings)); + entry.setFormattedTimeRange(TimeUtil.formatTimeRange(visit.getStartTime(), visit.getEndTime(), timezone, selectedDate, userSettings)); + entry.setFormattedLocalTimeRange(TimeUtil.formatTimeRange(visit.getStartTime(), visit.getEndTime(), visit.getPlace().getTimezone(), selectedDate, userSettings)); entry.setFormattedDuration(formatDuration(visit.getStartTime(), visit.getEndTime())); + entry.setEditable(ownData); entries.add(entry); } } // Add trips to timeline for (Trip trip : trips) { - TimelineEntry entry = new TimelineEntry(); + SingleTimelineEntry entry = new SingleTimelineEntry(); entry.setId("trip-" + trip.getId()); entry.setResourceId(trip.getId()); - entry.setType(TimelineEntry.Type.TRIP); + entry.setType(SingleTimelineEntry.Type.TRIP); entry.setStartTime(trip.getStartTime()); entry.setStartTimezone(trip.getStartVisit().getPlace().getTimezone()); entry.setEndTime(trip.getEndTime()); entry.setEndTimezone(trip.getEndVisit().getPlace().getTimezone()); - entry.setFormattedTimeRange(formatTimeRange(trip.getStartTime(), trip.getEndTime(), timezone, selectedDate, userSettings)); + entry.setFormattedTimeRange(TimeUtil.formatTimeRange(trip.getStartTime(), trip.getEndTime(), timezone, selectedDate, userSettings)); entry.setFormattedDuration(formatDuration(trip.getStartTime(), trip.getEndTime())); - entry.setFormattedLocalTimeRange(formatTimeRange(trip.getStartTime(), trip.getEndTime(), trip.getStartVisit().getPlace().getTimezone(), trip.getEndVisit().getPlace().getTimezone(), selectedDate, userSettings)); + entry.setFormattedLocalTimeRange(TimeUtil.formatTimeRange(trip.getStartTime(), trip.getEndTime(), trip.getStartVisit().getPlace().getTimezone(), trip.getEndVisit().getPlace().getTimezone(), selectedDate, userSettings)); + entry.setEditable(ownData); if (trip.getTravelledDistanceMeters() != null) { entry.setDistanceMeters(trip.getTravelledDistanceMeters()); @@ -129,41 +130,11 @@ private List buildTimelineEntries(List processedV entries.add(entry); } - entries.sort(Comparator.comparing(TimelineEntry::getStartTime)); + entries.sort(Comparator.comparing(SingleTimelineEntry::getStartTime)); return entries; } - private String formatTimeRange(Instant startTime, Instant endTime, ZoneId startTimezone, ZoneId endTimezone, LocalDate selectedDate, UserSettings userSettings) { - DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("MMM d " + userSettings.getTimeMode().getPattern()); - - LocalDate startDate = startTime.atZone(startTimezone).toLocalDate(); - LocalDate endDate = endTime.atZone(endTimezone).toLocalDate(); - LocalDate selectedDateInStartTimezone = selectedDate.atTime(10,0).atZone(startTimezone).toLocalDate(); - LocalDate selectedDateInEndTimezone = selectedDate.atTime(10,0).atZone(endTimezone).toLocalDate(); - String start, end; - - // If start time is not on the selected date, show date + time - if (!startDate.equals(selectedDateInStartTimezone)) { - start = startTime.atZone(startTimezone).format(dateTimeFormatter); - } else { - start = userSettings.getTimeMode().format(startTime, startTimezone); - } - - // If end time is not on the selected date, show date + time - if (!endDate.equals(selectedDateInEndTimezone)) { - end = endTime.atZone(endTimezone).format(dateTimeFormatter); - } else { - end = userSettings.getTimeMode().format(endTime, endTimezone); - } - - return start + " - " + end; - } - - private String formatTimeRange(Instant startTime, Instant endTime, ZoneId timezone, LocalDate selectedDate, UserSettings userSettings) { - return formatTimeRange(startTime, endTime, timezone, timezone, selectedDate, userSettings); - } - /** * Format duration for display (this is a simple implementation, you might want to use HumanizeDuration) */ diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserNotificationService.java b/src/main/java/com/dedicatedcode/reitti/service/UserNotificationService.java index ba13904c6..8a624d6e5 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserNotificationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserNotificationService.java @@ -4,6 +4,7 @@ import com.dedicatedcode.reitti.event.SSEEvent; import com.dedicatedcode.reitti.event.SSEType; import com.dedicatedcode.reitti.model.NotificationData; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.geo.ProcessedVisit; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.geo.Trip; @@ -12,7 +13,10 @@ import com.dedicatedcode.reitti.repository.UserJdbcService; import com.dedicatedcode.reitti.repository.UserSharingJdbcService; import com.dedicatedcode.reitti.service.integration.ReittiSubscriptionService; -import com.dedicatedcode.reitti.service.queue.RedisQueueService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.TimeRange; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -26,35 +30,27 @@ import java.util.Set; import java.util.stream.Collectors; +import static com.dedicatedcode.reitti.service.jobs.JobSchedulingService.Metadata; + @Service public class UserNotificationService { private static final Logger log = LoggerFactory.getLogger(UserNotificationService.class); - private final RedisQueueService messageEnqueuer; private final ReittiSubscriptionService reittiSubscriptionService; private final UserJdbcService userJdbcService; private final UserSharingJdbcService userSharingJdbcService; + private final JobSchedulingService jobScheduler; + private final Task userSSEEmitterTask; - public UserNotificationService(RedisQueueService messageEnqueuer, + public UserNotificationService(JobSchedulingService jobScheduler, ReittiSubscriptionService reittiSubscriptionService, UserJdbcService userJdbcService, - UserSharingJdbcService userSharingJdbcService) { - this.messageEnqueuer = messageEnqueuer; + UserSharingJdbcService userSharingJdbcService, + Task userSSEEmitterTask) { + this.jobScheduler = jobScheduler; this.reittiSubscriptionService = reittiSubscriptionService; this.userJdbcService = userJdbcService; this.userSharingJdbcService = userSharingJdbcService; - } - - public void newTrips(User user, List trips) { - newTrips(user, trips, null); - } - - public void newTrips(User user, List trips, String previewId) { - SSEType eventType = SSEType.TRIPS; - log.debug("New trips for user [{}]", user.getId()); - Set dates = calculateAffectedDates(trips.stream().map(Trip::getStartTime).toList(), trips.stream().map(Trip::getEndTime).toList()); - sendToQueue(user, dates, eventType, previewId); - notifyOtherUsers(user, eventType, dates); - notifyReittiSubscriptions(user, eventType, dates); + this.userSSEEmitterTask = userSSEEmitterTask; } public void placeUpdate(User user, SignificantPlace place, String previewId) { @@ -72,6 +68,19 @@ public void newVisits(User user, List processedVisits) { notifyReittiSubscriptions(user, eventType, dates); } + public void newTrips(User user, List trips) { + newTrips(user, trips, null); + } + + public void newTrips(User user, List trips, String previewId) { + SSEType eventType = SSEType.TRIPS; + log.debug("New trips for user [{}]", user.getId()); + Set dates = calculateAffectedDates(trips.stream().map(Trip::getStartTime).toList(), trips.stream().map(Trip::getEndTime).toList()); + sendToQueue(user, dates, eventType, previewId); + notifyOtherUsers(user, eventType, dates); + notifyReittiSubscriptions(user, eventType, dates); + } + public void newRawLocationData(User user, List filtered) { SSEType eventType = SSEType.RAW_DATA; log.debug("New RawLocationPoints for user [{}]", user.getId()); @@ -81,19 +90,32 @@ public void newRawLocationData(User user, List filtered) { notifyReittiSubscriptions(user, eventType, dates); } + public void newLocationData(User user, Device device, TimeRange timeRange) { + SSEType eventType = SSEType.RAW_DATA; + log.debug("New RawLocationPoints for user [{}] and device [{}]", user.getId(), device.id()); + Set dates = calculateAffectedDates(timeRange); + sendToQueue(user, dates, eventType, null); + notifyOtherUsers(user, eventType, dates); + notifyReittiSubscriptions(user, eventType, dates); + } + + public void sendToQueue(User user, Set dates, SSEType eventType, String previewId) { for (LocalDate date : dates) { - this.messageEnqueuer.enqueue(MessageDispatcherService.USER_EVENT_QUEUE, new SSEEvent(eventType, user.getId(), user.getId(), date, previewId)); + this.jobScheduler.enqueueTask(this.userSSEEmitterTask, new UserSseEmitterService.TaskData(user, new SSEEvent(eventType, user.getId(), user.getId(), date, previewId)), + Metadata.builder().user(user).jobType(JobType.SSE_EVENT).friendlyName("Send updates to clients").build()); } } public void sendToQueue(User user, User changedUser, Set dates, SSEType eventType, String previewId) { for (LocalDate date : dates) { - this.messageEnqueuer.enqueue(MessageDispatcherService.USER_EVENT_QUEUE, new SSEEvent(eventType, user.getId(), changedUser.getId(), date, previewId)); + this.jobScheduler.enqueueTask(this.userSSEEmitterTask, new UserSseEmitterService.TaskData(user, new SSEEvent(eventType, user.getId(), changedUser.getId(), date, previewId)), + Metadata.builder().user(user).jobType(JobType.SSE_EVENT).friendlyName("Send updates to clients").build()); } } private void sendToQueue(User user, SSEType eventType, String previewId) { - this.messageEnqueuer.enqueue(MessageDispatcherService.USER_EVENT_QUEUE, new SSEEvent(eventType, user.getId(), user.getId(), null, previewId)); + this.jobScheduler.enqueueTask(this.userSSEEmitterTask, new UserSseEmitterService.TaskData(user, new SSEEvent(eventType, user.getId(), user.getId(), null, previewId)), + Metadata.builder().user(user).jobType(JobType.SSE_EVENT).friendlyName("Send updates to clients").build()); } private void notifyOtherUsers(User user, SSEType eventType, Set dates) { @@ -131,4 +153,18 @@ private Set calculateAffectedDates(List... list) { } } + + private Set calculateAffectedDates(TimeRange timeRange) { + Set result = new HashSet<>(); + if (timeRange != null && timeRange.start() != null && timeRange.end() != null) { + LocalDate startDate = timeRange.start().atZone(ZoneId.of("Z")).toLocalDate(); + LocalDate endDate = timeRange.end().atZone(ZoneId.of("Z")).toLocalDate(); + LocalDate current = startDate; + while (!current.isAfter(endDate)) { + result.add(current); + current = current.plusDays(1); + } + } + return result; } + } diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserService.java b/src/main/java/com/dedicatedcode/reitti/service/UserService.java index 43db8c804..c811a07aa 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserService.java @@ -1,11 +1,13 @@ package com.dedicatedcode.reitti.service; import com.dedicatedcode.reitti.model.*; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.geo.GeoPoint; import com.dedicatedcode.reitti.model.geo.TransportMode; import com.dedicatedcode.reitti.model.geo.TransportModeConfig; import com.dedicatedcode.reitti.model.processing.DetectionParameter; import com.dedicatedcode.reitti.model.processing.RecalculationState; +import com.dedicatedcode.reitti.model.security.ApiToken; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.model.security.UserSettings; import com.dedicatedcode.reitti.repository.*; @@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.time.Instant; import java.time.ZoneId; import java.util.List; import java.util.Optional; @@ -33,6 +36,9 @@ public class UserService { private final ApiTokenJdbcService apiTokenJdbcService; private final MqttIntegrationJdbcService mqttIntegrationJdbcService; private final PasswordEncoder passwordEncoder; + private final UserMapStyleJdbcService userMapStyleJdbcService; + private final DeviceJdbcService deviceJdbcService; + private final ApiTokenService apiTokenService; private final JdbcTemplate jdbcTemplate; public UserService(UserJdbcService userJdbcService, @@ -45,7 +51,7 @@ public UserService(UserJdbcService userJdbcService, GeocodingResponseJdbcService geocodingResponseJdbcService, ApiTokenJdbcService apiTokenJdbcService, MqttIntegrationJdbcService mqttIntegrationJdbcService, - PasswordEncoder passwordEncoder, + PasswordEncoder passwordEncoder, UserMapStyleJdbcService userMapStyleJdbcService, DeviceJdbcService deviceJdbcService, ApiTokenService apiTokenService, JdbcTemplate jdbcTemplate) { this.userJdbcService = userJdbcService; this.userSettingsJdbcService = userSettingsJdbcService; @@ -59,9 +65,13 @@ public UserService(UserJdbcService userJdbcService, this.apiTokenJdbcService = apiTokenJdbcService; this.mqttIntegrationJdbcService = mqttIntegrationJdbcService; this.passwordEncoder = passwordEncoder; + this.userMapStyleJdbcService = userMapStyleJdbcService; + this.deviceJdbcService = deviceJdbcService; + this.apiTokenService = apiTokenService; this.jdbcTemplate = jdbcTemplate; } + @Transactional public User createNewUser(String username, String displayName, String externalId, @@ -75,16 +85,33 @@ public User createNewUser(String username, userSettings = addRandomHomeLocation(userSettings); saveDefaultVisitDetectionParameters(createdUser); saveDefaultTransportationModeDetectionParameters(createdUser); + setDefaultMapStyle(createdUser); + createDefaultDeviceForUser(createdUser); this.userSettingsJdbcService.save(userSettings); return createdUser; } + private void createDefaultDeviceForUser(User createdUser) { + ApiToken token = this.apiTokenService.createToken(createdUser, "Default"); + Device saved = this.deviceJdbcService.save(new Device(null, "Default", true, true, true, "#f1ba63", true, Instant.now(), Instant.now(), 0L), createdUser); + token = token.withDevice(saved); + this.apiTokenJdbcService.save(token); + } + + private void setDefaultMapStyle(User createdUser) { + Long defaultStyleId = jdbcTemplate.queryForObject( + "SELECT id FROM user_map_styles WHERE name = 'Reitti' LIMIT 1", + Long.class); + if (defaultStyleId != null) { + userMapStyleJdbcService.setActiveStyleId(createdUser, defaultStyleId); + } + } + public User createNewUser(String username, String displayName, String password, Role role, UnitSystem unitSystem, - boolean preferColoredMap, Language preferredLanguage, Double homeLatitude, Double homeLongitude, @@ -97,7 +124,6 @@ public User createNewUser(String username, .withRole(role)); UserSettings userSettings = new UserSettings(createdUser.getId(), - preferColoredMap, preferredLanguage, unitSystem, homeLatitude, @@ -113,7 +139,7 @@ public User createNewUser(String username, if (userSettings.getHomeLatitude() == null && userSettings.getHomeLongitude() == null) { userSettings = addRandomHomeLocation(userSettings); } - + setDefaultMapStyle(createdUser); saveDefaultVisitDetectionParameters(createdUser); saveDefaultTransportationModeDetectionParameters(createdUser); userSettingsJdbcService.save(userSettings); @@ -158,9 +184,12 @@ public void deleteUser(User user) { this.significantPlaceJdbcService.deleteForUser(user); this.significantPlaceOverrideJdbcService.deleteForUser(user); this.rawLocationPointJdbcService.deleteAllForUser(user); - this.rawLocationPointJdbcService.deleteAllForUser(user); this.apiTokenJdbcService.deleteForUser(user); this.mqttIntegrationJdbcService.deleteForUser(user); + // Delete the row in the map style settings table + this.jdbcTemplate.update("DELETE FROM user_map_style_settings WHERE user_id = ?", user.getId()); + this.jdbcTemplate.update("DELETE FROM user_map_styles WHERE user_id = ?", user.getId()); + this.deviceJdbcService.deleteForUser(user); this.userJdbcService.deleteUser(user.getId()); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserSseEmitterService.java b/src/main/java/com/dedicatedcode/reitti/service/UserSseEmitterService.java index 0ad561e64..106f9df31 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserSseEmitterService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserSseEmitterService.java @@ -11,8 +11,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; @@ -20,7 +19,7 @@ public class UserSseEmitterService implements SmartLifecycle { private static final Logger log = LoggerFactory.getLogger(UserSseEmitterService.class); private final ReittiIntegrationService reittiIntegrationService; - private final Map> userEmitters = new ConcurrentHashMap<>(); + private final Map> userEmitters = new ConcurrentHashMap<>(); public UserSseEmitterService(ReittiIntegrationService reittiIntegrationService) { this.reittiIntegrationService = reittiIntegrationService; @@ -28,7 +27,7 @@ public UserSseEmitterService(ReittiIntegrationService reittiIntegrationService) public SseEmitter addEmitter(User user) { SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); - userEmitters.computeIfAbsent(user, _ -> new CopyOnWriteArraySet<>()).add(emitter); + userEmitters.computeIfAbsent(user.getId(), _ -> new CopyOnWriteArraySet<>()).add(emitter); emitter.onCompletion(() -> { log.debug("SSE connection completed for user: [{}]", user); removeEmitter(user, emitter); @@ -49,12 +48,12 @@ public SseEmitter addEmitter(User user) { } catch (IOException e) { log.error("Unable to send initial event for user [{}]", user, e); } - log.info("Emitter added for user: {}. Total emitters for user: {}", user, userEmitters.get(user).size()); + log.info("Emitter added for user: {}. Total emitters for user: {}", user, userEmitters.get(user.getId()).size()); return emitter; } public void sendEventToUser(User user, SSEEvent eventData) { - Set emitters = userEmitters.get(user); + Set emitters = userEmitters.get(user.getId()); if (emitters != null) { for (SseEmitter emitter : new CopyOnWriteArraySet<>(emitters)) { try { @@ -70,14 +69,14 @@ public void sendEventToUser(User user, SSEEvent eventData) { } private void removeEmitter(User user, SseEmitter emitter) { - Set emitters = userEmitters.get(user); + Set emitters = userEmitters.get(user.getId()); if (emitters != null) { emitters.remove(emitter); if (emitters.isEmpty()) { - userEmitters.remove(user); + userEmitters.remove(user.getId()); reittiIntegrationService.unsubscribeFromIntegrations(user); } - log.info("Emitter removed for user: {}. Remaining emitters for user: {}", user, userEmitters.containsKey(user) ? userEmitters.get(user).size() : 0); + log.info("Emitter removed for user: {}. Remaining emitters for user: {}", user, userEmitters.getOrDefault(user.getId(), Collections.emptySet()).size()); } } @@ -94,4 +93,64 @@ public void stop() { public boolean isRunning() { return true; } + + public static final class TaskData extends JobContext { + private final User user; + private final SSEEvent eventData; + + public TaskData(User user, SSEEvent eventData) { + this(user, eventData, null, null); + } + + public TaskData(User user, SSEEvent eventData, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + this.eventData = eventData; + } + + public User user() { + return user; + } + + public SSEEvent eventData() { + return eventData; + } + + public UUID parentJobId() { + return parentJobId; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (TaskData) obj; + return Objects.equals(this.user, that.user) && + Objects.equals(this.eventData, that.eventData) && + Objects.equals(this.parentJobId, that.parentJobId); + } + + @Override + public int hashCode() { + return Objects.hash(user, eventData, parentJobId); + } + + @Override + public String toString() { + return "TaskData[" + + "user=" + user + ", " + + "eventData=" + eventData + ", " + + "parentJobId=" + parentJobId + ']'; + } + + @Override + public TaskData withJobId(UUID jobId) { + return new TaskData(user, eventData, jobId, parentJobId); + } + + @Override + public TaskData withParentJobId(UUID parentJobId) { + return new TaskData(user, eventData, jobId, parentJobId); + } + } } \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/VisitDetectionPreviewService.java b/src/main/java/com/dedicatedcode/reitti/service/VisitDetectionPreviewService.java index f0133dee9..15ac80fb8 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/VisitDetectionPreviewService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/VisitDetectionPreviewService.java @@ -3,7 +3,8 @@ import com.dedicatedcode.reitti.event.TriggerProcessingEvent; import com.dedicatedcode.reitti.model.processing.DetectionParameter; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.queue.RedisQueueService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; @@ -17,7 +18,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import static com.dedicatedcode.reitti.service.MessageDispatcherService.TRIGGER_PROCESSING_QUEUE; +import static com.dedicatedcode.reitti.service.jobs.JobType.VISIT_TRIP_DETECTION; @Service public class VisitDetectionPreviewService { @@ -26,12 +27,16 @@ public class VisitDetectionPreviewService { private static final long READY_THRESHOLD_SECONDS = 5; private final JdbcTemplate jdbcTemplate; - private final RedisQueueService messageEnqueuer; + private final JobSchedulingService jobScheduler; + private final Task processingEventTask; private final Map previewLastUpdated = new ConcurrentHashMap<>(); - public VisitDetectionPreviewService(JdbcTemplate jdbcTemplate, RedisQueueService messageEnqueuer) { + public VisitDetectionPreviewService(JdbcTemplate jdbcTemplate, + JobSchedulingService jobScheduler, + Task processingEventTask) { this.jdbcTemplate = jdbcTemplate; - this.messageEnqueuer = messageEnqueuer; + this.jobScheduler = jobScheduler; + this.processingEventTask = processingEventTask; } public String startPreview(User user, DetectionParameter config, Instant date) { @@ -56,8 +61,8 @@ INSERT INTO preview_visit_detection_parameters(user_id, valid_since, detection_m Timestamp start = Timestamp.from(date.minus(config.getVisitMerging().getSearchDurationInHours() * 2, ChronoUnit.HOURS)); Timestamp end = Timestamp.from(date.plus(1, ChronoUnit.DAYS).plus(config.getVisitMerging().getSearchDurationInHours() * 2, ChronoUnit.HOURS)); - this.jdbcTemplate.update("INSERT INTO preview_raw_location_points(accuracy_meters, timestamp, user_id, elevation_meters, geom, processed, version, ignored, synthetic, preview_id, preview_created_at) " + - "SELECT accuracy_meters, timestamp, user_id, elevation_meters, geom, false, version, ignored, synthetic, ?, ? FROM raw_location_points WHERE timestamp > ? AND timestamp <= ? AND user_id = ? AND invalid = false", + this.jdbcTemplate.update("INSERT INTO preview_raw_location_points(accuracy_meters, timestamp, user_id, elevation_meters, geom, processed, version, synthetic, preview_id, preview_created_at) " + + "SELECT accuracy_meters, timestamp, user_id, elevation_meters, geom, FALSE, version, synthetic, ?, ? FROM raw_location_points WHERE timestamp > ? AND timestamp <= ? AND user_id = ?", previewId, Timestamp.valueOf(now), start, @@ -65,10 +70,12 @@ INSERT INTO preview_visit_detection_parameters(user_id, valid_since, detection_m user.getId()); log.debug("Copied preview data user [{}] with previewId [{}] successfully", user.getId(), previewId); - TriggerProcessingEvent triggerEvent = new TriggerProcessingEvent(user.getUsername(), previewId, UUID.randomUUID().toString()); - messageEnqueuer.enqueue(TRIGGER_PROCESSING_QUEUE, triggerEvent); - - // Initialize preview status tracking + UUID parentJob = this.jobScheduler.createParentJob(user, VISIT_TRIP_DETECTION, previewId); + TriggerProcessingEvent triggerEvent = new TriggerProcessingEvent(user.getUsername(), previewId, UUID.randomUUID().toString()).withParentJobId(parentJob); + this.jobScheduler.enqueueTask(processingEventTask, triggerEvent, + JobSchedulingService.Metadata.builder().user(user).jobType(VISIT_TRIP_DETECTION) + .friendlyName("Preview Visit Detection") + .build()); updatePreviewStatus(previewId); return previewId; diff --git a/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java b/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java index 6a91cddcf..fc2c5dad7 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java +++ b/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java @@ -6,6 +6,7 @@ import com.dedicatedcode.reitti.repository.GeocodeServiceJdbcService; import com.dedicatedcode.reitti.repository.GeocodingResponseJdbcService; import com.dedicatedcode.reitti.service.I18nService; +import com.dedicatedcode.reitti.service.geocoding.services.NominatimRateLimiter; import com.dedicatedcode.reitti.service.geocoding.services.ResultHandler; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -29,6 +30,7 @@ public class DefaultGeocodeServiceManager implements GeocodeServiceManager { private final GeocodeServiceJdbcService geocodeServiceJdbcService; private final GeocodingResponseJdbcService geocodingResponseJdbcService; + private final NominatimRateLimiter nominatimRateLimiter; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final List resultHandlers; @@ -37,6 +39,7 @@ public class DefaultGeocodeServiceManager implements GeocodeServiceManager { public DefaultGeocodeServiceManager(GeocodeServiceJdbcService geocodeServiceJdbcService, GeocodingResponseJdbcService geocodingResponseJdbcService, + NominatimRateLimiter nominatimRateLimiter, RestTemplate restTemplate, ObjectMapper objectMapper, List resultHandlers, @@ -44,6 +47,7 @@ public DefaultGeocodeServiceManager(GeocodeServiceJdbcService geocodeServiceJdbc @Value("${reitti.geocoding.max-errors}") int maxErrors) { this.geocodeServiceJdbcService = geocodeServiceJdbcService; this.geocodingResponseJdbcService = geocodingResponseJdbcService; + this.nominatimRateLimiter = nominatimRateLimiter; this.restTemplate = restTemplate; this.objectMapper = objectMapper; this.resultHandlers = resultHandlers; @@ -89,6 +93,9 @@ public Map> reverseGeocodeAll(SignificantPlace } for (GeocodeService service : availableServices) { + if (service.getType() == GeocoderType.NOMINATIM) { + nominatimRateLimiter.acquireBlockingly(); + } List serviceResults = performGeocode(service, latitude, longitude, significantPlace, true); if (!serviceResults.isEmpty()) { results.computeIfAbsent(service.getType(), _ -> new ArrayList<>()) @@ -116,7 +123,12 @@ private Optional callGeocodeService(List shuffledServices = new ArrayList<>(availableServices); Collections.shuffle(shuffledServices); + List waitingServices = new ArrayList<>(); for (GeocodeService service : shuffledServices) { + if (service.getType() == GeocoderType.NOMINATIM && !nominatimRateLimiter.tryAcquire()) { + waitingServices.add(service); + continue; + } try { List result = performGeocode(service, latitude, longitude, significantPlace, recordResponse); if (!result.isEmpty()) { @@ -133,13 +145,33 @@ private Optional callGeocodeService(List handleWaitingServices(List waitingServices, double latitude, double longitude, SignificantPlace significantPlace, boolean recordResponse) { + for (GeocodeService service : waitingServices) { + this.nominatimRateLimiter.acquireBlockingly(); + try { + List result = performGeocode(service, latitude, longitude, significantPlace, recordResponse); + if (!result.isEmpty()) { + if (recordResponse) { + recordSuccess(service); + } + return result.stream().findFirst(); + } + } catch (Exception e) { + logger.warn("Calling awaited geocoding failed for service [{}]: [{}]", service.getName(), e.getMessage()); + if (recordResponse) { + recordError(service); + } + } + } return Optional.empty(); } private List performGeocode(GeocodeService service, double latitude, double longitude, SignificantPlace significantPlace, boolean recordResponse) { try { String response = callService(service, latitude, longitude); - List geocodeResult = extractGeoCodeResult(service.getType(), response); if (recordResponse && !geocodeResult.isEmpty()) { geocodingResponseJdbcService.insert(new GeocodingResponse( diff --git a/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimRateLimiter.java b/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimRateLimiter.java new file mode 100644 index 000000000..8989b4043 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimRateLimiter.java @@ -0,0 +1,46 @@ +package com.dedicatedcode.reitti.service.geocoding.services; + +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicLong; + +@Service +public class NominatimRateLimiter { + private final AtomicLong lastRequestTime = new AtomicLong(0); + private static final long WAIT_TIME_MS = 2000; + + /** + * Attempts to acquire a slot. If a second has passed, updates the timestamp and returns true. + * If not, returns false (caller must wait or skip). + */ + public boolean tryAcquire() { + long now = System.currentTimeMillis(); + long last = lastRequestTime.get(); + + if (now - last >= WAIT_TIME_MS) { + return lastRequestTime.compareAndSet(last, now); + } + return false; + } + + public void acquireBlockingly() { + while (true) { + long now = System.currentTimeMillis(); + long last = lastRequestTime.get(); + long timePassed = now - last; + + if (timePassed >= WAIT_TIME_MS) { + if (lastRequestTime.compareAndSet(last, now)) { + return; + } + } else { + try { + Thread.sleep(WAIT_TIME_MS - timePassed); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread interrupted while waiting for Nominatim rate limit", e); + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimResultHandler.java b/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimResultHandler.java index 428bb533b..012357541 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimResultHandler.java +++ b/src/main/java/com/dedicatedcode/reitti/service/geocoding/services/NominatimResultHandler.java @@ -8,7 +8,7 @@ import java.util.*; @Service -public class NominatimResultHandler implements ResultHandler{ +public class NominatimResultHandler implements ResultHandler { @Override public boolean canHandle(GeocoderType type) { return type == GeocoderType.NOMINATIM; diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java index 009255886..e98abc2f6 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java @@ -1,8 +1,9 @@ package com.dedicatedcode.reitti.service.importer; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.DefaultImportProcessor; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,22 +17,22 @@ public abstract class BaseGoogleTimelineImporter { private static final Logger logger = LoggerFactory.getLogger(BaseGoogleTimelineImporter.class); protected final ObjectMapper objectMapper; - protected final DefaultImportProcessor batchProcessor; + protected final LocationPointStagingService stagingService; public BaseGoogleTimelineImporter(ObjectMapper objectMapper, - DefaultImportProcessor batchProcessor) { + LocationPointStagingService stagingService) { this.objectMapper = objectMapper; - this.batchProcessor = batchProcessor; + this.stagingService = stagingService; } - protected int handleVisit(User user, ZonedDateTime startTime, ZonedDateTime endTime, LatLng latLng, List batch) { + protected int handleVisit(String partitionKey, User user, Device device, ZonedDateTime startTime, ZonedDateTime endTime, LatLng latLng, List batch) { logger.info("Found visit at [{}] from start [{}] to end [{}].", latLng, startTime, endTime); - createAndScheduleLocationPoint(latLng, startTime, user, batch); - createAndScheduleLocationPoint(latLng, endTime, user, batch); + createAndScheduleLocationPoint(latLng, startTime, partitionKey, user, device, batch); + createAndScheduleLocationPoint(latLng, endTime, partitionKey, user, device, batch); return 2; } - protected void createAndScheduleLocationPoint(LatLng latLng, ZonedDateTime timestamp, User user, List batch) { + protected void createAndScheduleLocationPoint(LatLng latLng, ZonedDateTime timestamp, String partitionKey, User user, Device device, List batch) { LocationPoint point = new LocationPoint(); point.setLatitude(latLng.latitude); point.setLongitude(latLng.longitude); @@ -39,8 +40,8 @@ protected void createAndScheduleLocationPoint(LatLng latLng, ZonedDateTime times point.setAccuracyMeters(10.0); batch.add(point); logger.trace("Created location point at [{}]", point); - if (batch.size() >= batchProcessor.getBatchSize()) { - batchProcessor.processBatch(user, batch); + if (batch.size() >= stagingService.getBatchSize()) { + stagingService.insertBatch(partitionKey, user, device, batch); batch.clear(); } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java index 0ad48d06a..d3ff9776d 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java @@ -1,13 +1,18 @@ package com.dedicatedcode.reitti.service.importer; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.DefaultImportProcessor; import com.dedicatedcode.reitti.service.ImportStateHolder; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.io.IOException; @@ -17,26 +22,39 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @Component public class GeoJsonImporter { - + private static final Logger logger = LoggerFactory.getLogger(GeoJsonImporter.class); - + private final ObjectMapper objectMapper; private final ImportStateHolder stateHolder; - private final DefaultImportProcessor batchProcessor; - - public GeoJsonImporter(ObjectMapper objectMapper, ImportStateHolder stateHolder, DefaultImportProcessor batchProcessor) { + private final LocationPointStagingService stagingService; + private final Task promotionTask; + private final JobSchedulingService jobSchedulingService; + private final int graceTimeSeconds; + + public GeoJsonImporter(ObjectMapper objectMapper, + ImportStateHolder stateHolder, + LocationPointStagingService stagingService, + Task promotionTask, + JobSchedulingService jobSchedulingService, + @Value("${reitti.import.grace-time-seconds:300}") int graceTimeSeconds) { this.objectMapper = objectMapper; this.stateHolder = stateHolder; - this.batchProcessor = batchProcessor; + this.stagingService = stagingService; + this.promotionTask = promotionTask; + this.jobSchedulingService = jobSchedulingService; + this.graceTimeSeconds = graceTimeSeconds; } - - public Map importGeoJson(InputStream inputStream, User user) { - AtomicInteger processedCount = new AtomicInteger(0); + public Map importGeoJson(InputStream inputStream, User user, Device device, String originalFilename) { + AtomicInteger processedCount = new AtomicInteger(0); + UUID parentJobId = null; + String partitionKey = null; try { stateHolder.importStarted(); logger.info("Importing GeoJSON file for user {}", user.getUsername()); @@ -46,9 +64,15 @@ public Map importGeoJson(InputStream inputStream, User user) { if (!rootNode.has("type")) { return Map.of("success", false, "error", "Invalid GeoJSON: missing 'type' field"); } - + partitionKey = UUID.randomUUID().toString(); + this.stagingService.ensurePartitionExists(partitionKey); + parentJobId = jobSchedulingService.createParentJob( + user, + JobType.GEOJSON_IMPORT, + "GeoJson Import - " + originalFilename + ); String type = rootNode.get("type").asText(); - List batch = new ArrayList<>(batchProcessor.getBatchSize()); + List batch = new ArrayList<>(stagingService.getBatchSize()); switch (type) { case "FeatureCollection" -> { @@ -64,8 +88,8 @@ public Map importGeoJson(InputStream inputStream, User user) { batch.add(point); processedCount.incrementAndGet(); - if (batch.size() >= batchProcessor.getBatchSize()) { - batchProcessor.processBatch(user, batch); + if (batch.size() >= stagingService.getBatchSize()) { + stagingService.insertBatch(partitionKey, user, device, batch); batch.clear(); } } @@ -94,15 +118,25 @@ public Map importGeoJson(InputStream inputStream, User user) { // Process any remaining locations if (!batch.isEmpty()) { - batchProcessor.processBatch(user, batch); + stagingService.insertBatch(partitionKey, user, device, batch); } logger.info("Imported and queued {} location points from GeoJSON file for user [{}]", processedCount.get(), user.getUsername()); + + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.GEOJSON_IMPORT) + .friendlyName("GeoJson Data Promotion") + .build(); + jobSchedulingService.scheduleTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(user, device, partitionKey, true).withParentJobId(parentJobId), + Instant.now().plusSeconds(graceTimeSeconds), + metadata); if (processedCount.get() == 0) { return Map.of("success", false, - "error", "No valid location points found in GeoJSON", - "pointsReceived", 0); + "error", "No valid location points found in GeoJSON", + "pointsReceived", 0); } else { return Map.of( "success", true, @@ -112,12 +146,16 @@ public Map importGeoJson(InputStream inputStream, User user) { } } catch (IOException e) { logger.error("Error processing GeoJSON file", e); + if (parentJobId != null) { + this.jobSchedulingService.cancel(parentJobId); + this.stagingService.dropPartition(partitionKey); + } return Map.of("success", false, "error", "Error processing GeoJSON file: " + e.getMessage()); } finally { stateHolder.importFinished(); } } - + /** * Converts a GeoJSON Feature to our LocationPoint format */ @@ -199,7 +237,7 @@ private LocationPoint convertGeoJsonGeometry(JsonNode geometry, JsonNode propert // Try to extract elevation from coordinates (3rd element) or properties Double elevation = null; - + // First try coordinates array (GeoJSON can have [lon, lat, elevation]) if (coordinates.size() >= 3) { try { @@ -208,7 +246,7 @@ private LocationPoint convertGeoJsonGeometry(JsonNode geometry, JsonNode propert // Ignore invalid elevation in coordinates } } - + // If not found in coordinates, try properties if (elevation == null) { String[] elevationFields = {"elevation", "ele", "altitude", "alt", "height"}; @@ -223,7 +261,7 @@ private LocationPoint convertGeoJsonGeometry(JsonNode geometry, JsonNode propert } } } - + point.setElevationMeters(elevation); return point; diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java index 9dab2ecf3..a227e808c 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java @@ -1,28 +1,31 @@ package com.dedicatedcode.reitti.service.importer; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.DefaultImportProcessor; import com.dedicatedcode.reitti.service.ImportStateHolder; import com.dedicatedcode.reitti.service.importer.dto.GoogleTimelineData; import com.dedicatedcode.reitti.service.importer.dto.SemanticSegment; import com.dedicatedcode.reitti.service.importer.dto.TimelinePathPoint; import com.dedicatedcode.reitti.service.importer.dto.Visit; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.io.IOException; import java.io.InputStream; +import java.time.Instant; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; @Component @@ -30,24 +33,42 @@ public class GoogleAndroidTimelineImporter extends BaseGoogleTimelineImporter { private static final Logger logger = LoggerFactory.getLogger(GoogleAndroidTimelineImporter.class); private final ImportStateHolder stateHolder; + private final Task promotionTask; + private final JobSchedulingService jobSchedulingService; + private final int graceTimeSeconds; public GoogleAndroidTimelineImporter(ObjectMapper objectMapper, ImportStateHolder stateHolder, - DefaultImportProcessor batchProcessor) { - super(objectMapper, batchProcessor); + LocationPointStagingService stagingService, + Task promotionTask, + JobSchedulingService jobSchedulingService, + @Value("${reitti.import.grace-time-seconds:300}") int graceTimeSeconds) { + super(objectMapper, stagingService); this.stateHolder = stateHolder; + this.promotionTask = promotionTask; + this.jobSchedulingService = jobSchedulingService; + this.graceTimeSeconds = graceTimeSeconds; } - public Map importTimeline(InputStream inputStream, User user) { + public Map importTimeline(InputStream inputStream, User user, Device device, String originalFilename) { AtomicInteger processedCount = new AtomicInteger(0); - + UUID parentJobId = null; + String partitionKey = null; try { + partitionKey = UUID.randomUUID().toString(); + String finalPartitionKey = partitionKey; + this.stagingService.ensurePartitionExists(partitionKey); + parentJobId = jobSchedulingService.createParentJob( + user, + JobType.GOOGLE_TIMELINE_IMPORT, + "Google Timeline Android Import - " + originalFilename + ); logger.info("Importing Google Timeline Android file for user {}", user.getUsername()); this.stateHolder.importStarted(); JsonFactory factory = objectMapper.getFactory(); JsonParser parser = factory.createParser(inputStream); - - List batch = new ArrayList<>(batchProcessor.getBatchSize()); + + List batch = new ArrayList<>(stagingService.getBatchSize()); GoogleTimelineData timelineData = objectMapper.readValue(parser, GoogleTimelineData.class); List semanticSegments = timelineData.getSemanticSegments(); @@ -59,7 +80,7 @@ public Map importTimeline(InputStream inputStream, User user) { Visit visit = semanticSegment.getVisit(); Optional latLng = parseLatLng(visit.getTopCandidate().getPlaceLocation().getLatLng()); if (latLng.isPresent()) { - latLng.ifPresent(lng -> processedCount.addAndGet(handleVisit(user, start, end, lng, batch))); + latLng.ifPresent(lng -> processedCount.addAndGet(handleVisit(finalPartitionKey, user, device, start, end, lng, batch))); } } @@ -68,7 +89,7 @@ public Map importTimeline(InputStream inputStream, User user) { logger.info("Found timeline path from start [{}] to end [{}]. Will insert [{}] geo locations based on timeline path.", semanticSegment.getStartTime(), semanticSegment.getEndTime(), timelinePath.size()); for (TimelinePathPoint timelinePathPoint : timelinePath) { parseLatLng(timelinePathPoint.getPoint()).ifPresent(location -> { - createAndScheduleLocationPoint(location, ZonedDateTime.parse(timelinePathPoint.getTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).withNano(0), user, batch); + createAndScheduleLocationPoint(location, ZonedDateTime.parse(timelinePathPoint.getTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).withNano(0), finalPartitionKey, user, device, batch); processedCount.incrementAndGet(); }); } @@ -77,9 +98,19 @@ public Map importTimeline(InputStream inputStream, User user) { // Process any remaining locations if (!batch.isEmpty()) { - batchProcessor.processBatch(user, batch); + stagingService.insertBatch(partitionKey, user, device, batch); } - + + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.GOOGLE_TIMELINE_IMPORT) + .friendlyName("GPS Data Promotion") + .build(); + jobSchedulingService.scheduleTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(user, device, partitionKey, true).withParentJobId(parentJobId), + Instant.now().plusSeconds(graceTimeSeconds), + metadata); + logger.info("Successfully imported and queued {} location points from Google Timeline for user {}", processedCount.get(), user.getUsername()); @@ -91,6 +122,10 @@ public Map importTimeline(InputStream inputStream, User user) { } catch (IOException e) { logger.error("Error processing Google Timeline file", e); + if (parentJobId != null) { + this.jobSchedulingService.cancel(parentJobId); + this.stagingService.dropPartition(partitionKey); + } return Map.of("success", false, "error", "Error processing Google Timeline file: " + e.getMessage()); } finally { stateHolder.importFinished(); diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java index 4d7e2b1c6..bf52abc6f 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java @@ -1,51 +1,73 @@ package com.dedicatedcode.reitti.service.importer; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.DefaultImportProcessor; import com.dedicatedcode.reitti.service.ImportStateHolder; import com.dedicatedcode.reitti.service.importer.dto.ios.IOSSemanticSegment; import com.dedicatedcode.reitti.service.importer.dto.ios.IOSVisit; +import com.dedicatedcode.reitti.service.importer.dto.ios.TimelinePathPoint; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.io.IOException; import java.io.InputStream; +import java.time.Instant; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; @Component public class GoogleIOSTimelineImporter extends BaseGoogleTimelineImporter { private static final Logger logger = LoggerFactory.getLogger(GoogleIOSTimelineImporter.class); private final ImportStateHolder stateHolder; + private final Task promotionTask; + private final JobSchedulingService jobSchedulingService; + private final int graceTimeSeconds; public GoogleIOSTimelineImporter(ObjectMapper objectMapper, ImportStateHolder stateHolder, - DefaultImportProcessor batchProcessor) { - super(objectMapper, batchProcessor); + LocationPointStagingService stagingService, + Task promotionTask, + JobSchedulingService jobSchedulingService, + @Value("${reitti.import.grace-time-seconds:300}") int graceTimeSeconds) { + super(objectMapper, stagingService); this.stateHolder = stateHolder; + this.promotionTask = promotionTask; + this.jobSchedulingService = jobSchedulingService; + this.graceTimeSeconds = graceTimeSeconds; } - public Map importTimeline(InputStream inputStream, User user) { + public Map importTimeline(InputStream inputStream, User user, Device device, String originalFilename) { AtomicInteger processedCount = new AtomicInteger(0); - + UUID parentJobId = null; + String partitionKey = null; try { logger.info("Importing Google Timeline IOS file for user {}", user.getUsername()); stateHolder.importStarted(); + partitionKey = UUID.randomUUID().toString(); + String finalPartitionKey = partitionKey; + this.stagingService.ensurePartitionExists(partitionKey); + parentJobId = jobSchedulingService.createParentJob( + user, + JobType.GOOGLE_TIMELINE_IMPORT, + "Google Timeline IOS Import - " + originalFilename + ); JsonFactory factory = objectMapper.getFactory(); JsonParser parser = factory.createParser(inputStream); - List batch = new ArrayList<>(batchProcessor.getBatchSize()); + List batch = new ArrayList<>(stagingService.getBatchSize()); List semanticSegments = objectMapper.readValue(parser, new TypeReference<>() {}); logger.info("Found {} semantic segments", semanticSegments.size()); @@ -55,16 +77,16 @@ public Map importTimeline(InputStream inputStream, User user) { if (semanticSegment.getVisit() != null) { IOSVisit visit = semanticSegment.getVisit(); Optional latLng = parseLatLng(visit.getTopCandidate().getPlaceLocation()); - latLng.ifPresent(lng -> processedCount.addAndGet(handleVisit(user, start, end, lng, batch))); + latLng.ifPresent(lng -> processedCount.addAndGet(handleVisit(finalPartitionKey, user,device, start, end, lng, batch))); } if (semanticSegment.getTimelinePath() != null) { - List timelinePath = semanticSegment.getTimelinePath(); + List timelinePath = semanticSegment.getTimelinePath(); logger.info("Found timeline path from start [{}] to end [{}]. Will insert [{}] synthetic geo locations based on timeline path.", semanticSegment.getStartTime(), semanticSegment.getEndTime(), timelinePath.size()); - for (com.dedicatedcode.reitti.service.importer.dto.ios.TimelinePathPoint timelinePathPoint : timelinePath) { + for (TimelinePathPoint timelinePathPoint : timelinePath) { parseLatLng(timelinePathPoint.getPoint()).ifPresent(location -> { ZonedDateTime current = start.plusMinutes(Long.parseLong(timelinePathPoint.getDurationMinutesOffsetFromStartTime())); - createAndScheduleLocationPoint(location, current, user, batch); + createAndScheduleLocationPoint(location, current, finalPartitionKey, user, device, batch); processedCount.incrementAndGet(); }); } @@ -73,8 +95,17 @@ public Map importTimeline(InputStream inputStream, User user) { // Process any remaining locations if (!batch.isEmpty()) { - batchProcessor.processBatch(user, batch); + stagingService.insertBatch(partitionKey, user, device, batch); } + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.GOOGLE_TIMELINE_IMPORT) + .friendlyName("GPS Data Promotion") + .build(); + jobSchedulingService.scheduleTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(user, device, partitionKey, true).withParentJobId(parentJobId), + Instant.now().plusSeconds(graceTimeSeconds), + metadata); logger.info("Successfully imported and queued {} location points from Google Timeline for user {}", processedCount.get(), user.getUsername()); @@ -87,6 +118,10 @@ public Map importTimeline(InputStream inputStream, User user) { } catch (IOException e) { logger.error("Error processing Google Timeline file", e); + if (parentJobId != null) { + this.jobSchedulingService.cancel(parentJobId); + this.stagingService.dropPartition(partitionKey); + } return Map.of("success", false, "error", "Error processing Google Timeline file: " + e.getMessage()); } finally { stateHolder.importFinished(); diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java index ca4729c97..7ac7e9857 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java @@ -1,24 +1,31 @@ package com.dedicatedcode.reitti.service.importer; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.DefaultImportProcessor; import com.dedicatedcode.reitti.service.ImportStateHolder; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.io.IOException; import java.io.InputStream; +import java.time.Instant; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @Component @@ -28,25 +35,44 @@ public class GoogleRecordsImporter { private final ObjectMapper objectMapper; private final ImportStateHolder stateHolder; - private final DefaultImportProcessor batchProcessor; - - public GoogleRecordsImporter(ObjectMapper objectMapper, ImportStateHolder stateHolder, DefaultImportProcessor batchProcessor) { + private final LocationPointStagingService stagingService; + private final Task promotionTask; + private final JobSchedulingService jobSchedulingService; + private final int graceTimeSeconds; + + public GoogleRecordsImporter(ObjectMapper objectMapper, + ImportStateHolder stateHolder, + LocationPointStagingService stagingService, + Task promotionTask, + JobSchedulingService jobSchedulingService, + @Value("${reitti.import.grace-time-seconds:300}") int graceTimeSeconds) { this.objectMapper = objectMapper; this.stateHolder = stateHolder; - this.batchProcessor = batchProcessor; + this.stagingService = stagingService; + this.promotionTask = promotionTask; + this.jobSchedulingService = jobSchedulingService; + this.graceTimeSeconds = graceTimeSeconds; } - public Map importGoogleRecords(InputStream inputStream, User user) { + public Map importGoogleRecords(InputStream inputStream, User user, Device device, String originalFilename) { AtomicInteger processedCount = new AtomicInteger(0); - + UUID parentJobId = null; + String partitionKey = null; try { stateHolder.importStarted(); logger.info("Importing Google Records file for user {}", user.getUsername()); JsonFactory factory = objectMapper.getFactory(); JsonParser parser = factory.createParser(inputStream); - - List batch = new ArrayList<>(batchProcessor.getBatchSize()); + parentJobId = jobSchedulingService.createParentJob( + user, + JobType.GOOGLE_TIMELINE_IMPORT, + "Google Records Import - " + originalFilename + ); + partitionKey = UUID.randomUUID().toString(); + stagingService.ensurePartitionExists(partitionKey); + + List batch = new ArrayList<>(stagingService.getBatchSize()); boolean foundData = false; // Look for "locations" array (old Records.json format) @@ -56,7 +82,7 @@ public Map importGoogleRecords(InputStream inputStream, User use if ("locations".equals(fieldName)) { foundData = true; - processedCount.addAndGet(processLocationsArray(parser, batch, user)); + processedCount.addAndGet(processLocationsArray(parser, batch, user, device, partitionKey)); break; } } @@ -68,12 +94,21 @@ public Map importGoogleRecords(InputStream inputStream, User use // Process any remaining locations if (!batch.isEmpty()) { - batchProcessor.processBatch(user, batch); + stagingService.insertBatch(partitionKey, user, device, batch); } logger.info("Successfully imported and queued {} location points from Google Records for user {}", processedCount.get(), user.getUsername()); - + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.GOOGLE_TIMELINE_IMPORT) + .friendlyName("GPS Data Promotion") + .build(); + jobSchedulingService.scheduleTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(user, device, partitionKey, true).withParentJobId(parentJobId), + Instant.now().plusSeconds(graceTimeSeconds), + metadata); + return Map.of( "success", true, "message", "Successfully queued " + processedCount.get() + " location points for processing", @@ -82,6 +117,10 @@ public Map importGoogleRecords(InputStream inputStream, User use } catch (IOException e) { logger.error("Error processing Google Records file", e); + if (parentJobId != null) { + this.jobSchedulingService.cancel(parentJobId); + this.stagingService.dropPartition(partitionKey); + } return Map.of("success", false, "error", "Error processing Google Records file: " + e.getMessage()); } finally { stateHolder.importFinished(); @@ -91,7 +130,7 @@ public Map importGoogleRecords(InputStream inputStream, User use /** * Processes the Records.json format with "locations" array */ - private int processLocationsArray(JsonParser parser, List batch, User user) throws IOException { + private int processLocationsArray(JsonParser parser, List batch, User user, Device device, String partitionKey) throws IOException { int processedCount = 0; // Move to the array @@ -113,8 +152,8 @@ private int processLocationsArray(JsonParser parser, List batch, batch.add(point); processedCount++; - if (batch.size() >= batchProcessor.getBatchSize()) { - batchProcessor.processBatch(user, batch); + if (batch.size() >= stagingService.getBatchSize()) { + stagingService.insertBatch(partitionKey, user, device, batch); batch.clear(); } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java index 403bd0620..e01a9ac47 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java @@ -1,151 +1,221 @@ package com.dedicatedcode.reitti.service.importer; import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.service.ImportProcessor; import com.dedicatedcode.reitti.service.ImportStateHolder; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamReader; import java.io.InputStream; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @Component public class GpxImporter { - + private static final Logger logger = LoggerFactory.getLogger(GpxImporter.class); private static final int BATCH_SIZE = 1000; private final ImportStateHolder stateHolder; - private final ImportProcessor batchProcessor; - - public GpxImporter(ImportStateHolder stateHolder, ImportProcessor batchProcessor) { + private final LocationPointStagingService stagingService; + private final Task promotionTask; + private final JobSchedulingService jobSchedulingService; + private final int graceTimeSeconds; + + public GpxImporter(ImportStateHolder stateHolder, + LocationPointStagingService stagingService, + Task promotionTask, + @Value("${reitti.import.grace-time-seconds:300}") int graceTimeSeconds, + JobSchedulingService jobSchedulingService) { this.stateHolder = stateHolder; - this.batchProcessor = batchProcessor; + this.stagingService = stagingService; + this.promotionTask = promotionTask; + this.graceTimeSeconds = graceTimeSeconds; + this.jobSchedulingService = jobSchedulingService; } - - public Map importGpx(InputStream inputStream, User user) { + + public Map importGpx(InputStream inputStream, User user, Device device, String originalFilename) { AtomicInteger processedCount = new AtomicInteger(0); - + + UUID parentJobId = null; + String partitionKey = null; try { stateHolder.importStarted(); logger.info("Importing GPX file for user {}", user.getUsername()); - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.parse(inputStream); - - // Normalize the XML structure - document.getDocumentElement().normalize(); - - // Get all track points (trkpt) from the GPX file - NodeList trackPoints = document.getElementsByTagName("trkpt"); - + parentJobId = jobSchedulingService.createParentJob( + user, + JobType.GPX_IMPORT, + "GPX Import - " + originalFilename + ); + partitionKey = UUID.randomUUID().toString(); + stagingService.ensurePartitionExists(partitionKey); + + XMLInputFactory factory = XMLInputFactory.newInstance(); + // Disable external entity processing for security + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + + XMLStreamReader reader = factory.createXMLStreamReader(inputStream); + List batch = new ArrayList<>(); - - // Process each track point - for (int i = 0; i < trackPoints.getLength(); i++) { - Element trackPoint = (Element) trackPoints.item(i); - - try { - LocationPoint point = convertGpxTrackPoint(trackPoint); - if (point != null) { - batch.add(point); - processedCount.incrementAndGet(); - - // Process in batches to avoid memory issues - if (batch.size() >= BATCH_SIZE) { - batchProcessor.processBatch(user, batch); - batch.clear(); + LocationPoint currentPoint = null; + StringBuilder currentText = new StringBuilder(); + + Double currentAccuracyValue = null; + Double currentHdopValue = null; + + while (reader.hasNext()) { + int event = reader.next(); + + switch (event) { + case XMLStreamConstants.START_ELEMENT: + String elementName = reader.getLocalName(); + currentText.setLength(0); + + if ("trkpt".equals(elementName)) { + String latAttr = reader.getAttributeValue(null, "lat"); + String lonAttr = reader.getAttributeValue(null, "lon"); + + if (latAttr == null || lonAttr == null) { + logger.warn("Track point missing lat or lon attribute, skipping"); + continue; + } + + currentPoint = new LocationPoint(); + double latitude = Double.parseDouble(latAttr); + double longitude = Double.parseDouble(lonAttr); + currentPoint.setLatitude(latitude); + currentPoint.setLongitude(longitude); + + currentAccuracyValue = null; + currentHdopValue = null; + } + break; + + case XMLStreamConstants.CHARACTERS: + if (currentPoint != null) { + currentText.append(reader.getText()); + } + break; + + case XMLStreamConstants.END_ELEMENT: + String endElementName = reader.getLocalName(); + + if ("trkpt".equals(endElementName) && currentPoint != null) { + // Finished processing this track point + + if (currentPoint.getTimestamp() != null) { + // Determine accuracy from optional or + double finalAccuracy; + if (currentAccuracyValue != null) { + finalAccuracy = currentAccuracyValue; + } else if (currentHdopValue != null) { + // Map HDOP to metres. Values above 5 indicate poor accuracy. + finalAccuracy = currentHdopValue > 5.0 ? 120.0 : 10.0; + } else { + finalAccuracy = 10.0; // default + } + currentPoint.setAccuracyMeters(finalAccuracy); + + batch.add(currentPoint); + processedCount.incrementAndGet(); + + // Process in batches to avoid memory issues + if (batch.size() >= BATCH_SIZE) { + stagingService.insertBatch(partitionKey, user, device, batch); + batch.clear(); + } + } else { + logger.warn("Track point missing timestamp, skipping"); + } + currentPoint = null; + } else if ("time".equals(endElementName) && currentPoint != null) { + String timeStr = currentText.toString().trim(); + if (StringUtils.hasText(timeStr)) { + currentPoint.setTimestamp(Instant.parse(timeStr)); + } + } else if ("ele".equals(endElementName) && currentPoint != null) { + String elevationStr = currentText.toString().trim(); + if (StringUtils.hasText(elevationStr)) { + try { + double elevation = Double.parseDouble(elevationStr); + currentPoint.setElevationMeters(elevation); + } catch (NumberFormatException e) { + // Ignore invalid elevation values + } + } + } else if ("accuracy".equals(endElementName) && currentPoint != null) { + String accStr = currentText.toString().trim(); + if (StringUtils.hasText(accStr)) { + try { + currentAccuracyValue = Double.parseDouble(accStr); + } catch (NumberFormatException ignored) { + // invalid accuracy value, keep null + } + } + } else if ("hdop".equals(endElementName) && currentPoint != null) { + String hdopStr = currentText.toString().trim(); + if (StringUtils.hasText(hdopStr)) { + try { + currentHdopValue = Double.parseDouble(hdopStr); + } catch (NumberFormatException ignored) { + // invalid hdop value, keep null + } + } } - } - } catch (Exception e) { - logger.warn("Error processing GPX track point: {}", e.getMessage()); - // Continue with next point + break; } } - + + reader.close(); + // Process any remaining locations if (!batch.isEmpty()) { - batchProcessor.processBatch(user, batch); + stagingService.insertBatch(partitionKey, user, device, batch); } - - logger.info("Successfully imported and queued {} location points from GPX file for user {}", - processedCount.get(), user.getUsername()); - + + logger.info("Successfully imported and queued [{}] location points from GPX file for user [{}]", processedCount.get(), user.getUsername()); + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.GPX_IMPORT) + .friendlyName("GPS Data Promotion") + .build(); + jobSchedulingService.scheduleTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(user, device, partitionKey, true).withParentJobId(parentJobId), + Instant.now().plusSeconds(graceTimeSeconds), + metadata); + return Map.of( "success", true, "message", "Successfully queued " + processedCount.get() + " location points for processing", "pointsReceived", processedCount.get() ); - + } catch (Exception e) { + if (parentJobId != null) { + this.jobSchedulingService.cancel(parentJobId); + this.stagingService.dropPartition(partitionKey); + } logger.error("Error processing GPX file", e); return Map.of("success", false, "error", "Error processing GPX file: " + e.getMessage()); } finally { stateHolder.importFinished(); } } - - /** - * Converts a GPX track point to our LocationPoint format - */ - private LocationPoint convertGpxTrackPoint(Element trackPoint) { - // Check if we have the required attributes - if (!trackPoint.hasAttribute("lat") || !trackPoint.hasAttribute("lon")) { - return null; - } - - LocationPoint point = new LocationPoint(); - - // Get latitude and longitude - double latitude = Double.parseDouble(trackPoint.getAttribute("lat")); - double longitude = Double.parseDouble(trackPoint.getAttribute("lon")); - - point.setLatitude(latitude); - point.setLongitude(longitude); - - // Get timestamp from the time element - NodeList timeElements = trackPoint.getElementsByTagName("time"); - if (timeElements.getLength() > 0) { - String timeStr = timeElements.item(0).getTextContent(); - if (StringUtils.hasText(timeStr)) { - point.setTimestamp(Instant.parse(timeStr)); - } else { - return null; - } - } else { - return null; - } - - // Set accuracy - GPX doesn't typically include accuracy, so use a default - point.setAccuracyMeters(10.0); // Default accuracy of 10 meters - - // Get elevation if available - NodeList elevationElements = trackPoint.getElementsByTagName("ele"); - if (elevationElements.getLength() > 0) { - String elevationStr = elevationElements.item(0).getTextContent(); - if (StringUtils.hasText(elevationStr)) { - try { - double elevation = Double.parseDouble(elevationStr); - point.setElevationMeters(elevation); - } catch (NumberFormatException e) { - // Ignore invalid elevation values - } - } - } - - return point; - } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/PromotionJobHandler.java b/src/main/java/com/dedicatedcode/reitti/service/importer/PromotionJobHandler.java new file mode 100644 index 000000000..e3338a1e0 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/importer/PromotionJobHandler.java @@ -0,0 +1,129 @@ +package com.dedicatedcode.reitti.service.importer; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.JobMetadataRepository; +import com.dedicatedcode.reitti.service.JobContext; +import com.dedicatedcode.reitti.service.UserNotificationService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationDataCleanupJob; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; +import com.dedicatedcode.reitti.service.processing.TimeRange; +import com.github.kagkarlsson.scheduler.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class PromotionJobHandler { + private static final Logger log = LoggerFactory.getLogger(PromotionJobHandler.class); + private final LocationPointStagingService stagingService; + private final JobSchedulingService jobSchedulingService; + private final JobMetadataRepository metadataRepository; + private final UserNotificationService userNotificationService; + private final Task locationDataCleanupTask; + + public PromotionJobHandler(LocationPointStagingService stagingService, + JobSchedulingService jobSchedulingService, + JobMetadataRepository metadataRepository, + UserNotificationService userNotificationService, + Task locationDataCleanupTask) { + this.stagingService = stagingService; + this.jobSchedulingService = jobSchedulingService; + this.metadataRepository = metadataRepository; + this.userNotificationService = userNotificationService; + this.locationDataCleanupTask = locationDataCleanupTask; + } + + public void execute(PromotionTaskData data) { + UUID jobId = data.getJobId(); + User user = data.getUser(); + String partitionKey = data.getPartitionKey(); + TimeRange timeRange = this.stagingService.getTimeRange(partitionKey); + metadataRepository.updateProgress(jobId, 0, 3, "Promoting points"); + int promote = this.stagingService.promote(partitionKey); + metadataRepository.updateProgress(jobId, 1, 3, "Dropping partition"); + + log.debug("Promoted [{}] points into live table", promote); + if (data.isManual()) { + this.stagingService.dropPartition(partitionKey); + } + metadataRepository.updateProgress(jobId, 2, 3, "Scheduling cleanup job"); + + if (promote > 0) { + this.userNotificationService.newLocationData(user, data.device, timeRange); + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.LOCATION_DATA_CLEANUP) + .friendlyName("Location Data Cleanup") + .build(); + this.jobSchedulingService.enqueueTask(locationDataCleanupTask, + new LocationDataCleanupJob.TaskData(user, data.getDevice(), timeRange.start(), timeRange.end()).withParentJobId(data.getParentJobId()), + metadata); + } else { + log.debug("No points to promote, timerange was [{}]", timeRange); + } + metadataRepository.updateProgress(jobId, 3, 3, "Done"); + } + + public static final class PromotionTaskData extends JobContext { + private final User user; + private final Device device; + private final String partitionKey; + private final boolean isManual; + + public PromotionTaskData(User user, Device device, String partitionKey, boolean isManual) { + this.user = user; + this.device = device; + this.partitionKey = partitionKey; + this.isManual = isManual; + } + + public PromotionTaskData(User user, Device device, String partitionKey, boolean isManual, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + this.device = device; + this.partitionKey = partitionKey; + this.isManual = isManual; + } + + public User getUser() { + return user; + } + + public Device getDevice() { + return device; + } + + public String getPartitionKey() { + return partitionKey; + } + + public boolean isManual() { + return isManual; + } + + @Override + public String toString() { + return "PromotionTaskData[" + + "user=" + user + ", " + + "device=" + device + ", " + + "partitionKey=" + partitionKey + ", " + + "isManual=" + isManual + ", " + + "parentJobId=" + parentJobId + ']'; + } + + @Override + public PromotionTaskData withJobId(UUID jobId) { + return new PromotionTaskData(user, device, partitionKey, isManual, jobId, parentJobId); + } + + @Override + public PromotionTaskData withParentJobId(UUID parentJobId) { + return new PromotionTaskData(user, device, partitionKey, isManual, jobId, parentJobId); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/OwnTracksRecorderIntegrationService.java b/src/main/java/com/dedicatedcode/reitti/service/integration/OwnTracksRecorderIntegrationService.java index cb6a6a8b2..b9bdec070 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/OwnTracksRecorderIntegrationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/OwnTracksRecorderIntegrationService.java @@ -2,12 +2,19 @@ import com.dedicatedcode.reitti.dto.LocationPoint; import com.dedicatedcode.reitti.dto.OwntracksLocationRequest; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.integration.OwnTracksRecorderIntegration; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; import com.dedicatedcode.reitti.repository.OwnTracksRecorderIntegrationJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; -import com.dedicatedcode.reitti.service.ImportProcessor; +import com.dedicatedcode.reitti.service.LocationBatchingService; +import com.dedicatedcode.reitti.service.importer.PromotionJobHandler; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.LocationPointStagingService; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.kagkarlsson.scheduler.task.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; @@ -34,15 +41,27 @@ public class OwnTracksRecorderIntegrationService { private final OwnTracksRecorderIntegrationJdbcService jdbcService; private final UserJdbcService userJdbcService; + private final DeviceJdbcService deviceJdbcService; private final RestTemplate restTemplate; - private final ImportProcessor importBatchProcessor; + private final LocationPointStagingService stagingService; + private final Task promotionTask; + private final JobSchedulingService jobSchedulingService; + private final LocationBatchingService locationBatchingService; public OwnTracksRecorderIntegrationService(OwnTracksRecorderIntegrationJdbcService jdbcService, UserJdbcService userJdbcService, - ImportProcessor importBatchProcessor) { + DeviceJdbcService deviceJdbcService, + LocationPointStagingService stagingService, + Task promotionTask, + JobSchedulingService jobSchedulingService, + LocationBatchingService locationBatchingService) { this.jdbcService = jdbcService; this.userJdbcService = userJdbcService; - this.importBatchProcessor = importBatchProcessor; + this.deviceJdbcService = deviceJdbcService; + this.stagingService = stagingService; + this.promotionTask = promotionTask; + this.jobSchedulingService = jobSchedulingService; + this.locationBatchingService = locationBatchingService; this.restTemplate = new RestTemplate(); } @@ -53,7 +72,7 @@ void importNewData() { List allUsers = userJdbcService.findAll(); int processedIntegrations = 0; int totalLocationPoints = 0; - + for (User user : allUsers) { Optional integrationOpt = jdbcService.findByUser(user); @@ -62,6 +81,7 @@ void importNewData() { } OwnTracksRecorderIntegration integration = integrationOpt.get(); + Device device = this.deviceJdbcService.find(user, integration.getReittiDeviceId()).orElseThrow(); processedIntegrations++; try { @@ -89,7 +109,7 @@ void importNewData() { } if (!validPoints.isEmpty()) { - importBatchProcessor.processBatch(user, validPoints); + validPoints.forEach(p -> this.locationBatchingService.addLocationPoint(user, device, p)); totalLocationPoints += validPoints.size(); logger.info("Imported {} location points for user {}", validPoints.size(), user.getUsername()); @@ -121,7 +141,7 @@ public Optional getIntegrationForUser(User user) { return jdbcService.findByUser(user); } - public OwnTracksRecorderIntegration saveIntegration(User user, String baseUrl, String username, String authUsername, String authPassword, String deviceId, boolean enabled) { + public OwnTracksRecorderIntegration saveIntegration(User user, Device device, String baseUrl, String username, String authUsername, String authPassword, String deviceId, boolean enabled) { // Validate inputs if (baseUrl == null || baseUrl.trim().isEmpty()) { throw new IllegalArgumentException("Base URL cannot be empty"); @@ -150,8 +170,8 @@ public OwnTracksRecorderIntegration saveIntegration(User user, String baseUrl, S deviceId.trim(), authUsername, authPassword, - enabled, - existing.getLastSuccessfulFetch(), existing.getVersion()); + device.id(), + enabled, existing.getLastSuccessfulFetch(), existing.getVersion()); return jdbcService.update(updated); } else { OwnTracksRecorderIntegration newIntegration = new OwnTracksRecorderIntegration( @@ -160,7 +180,8 @@ public OwnTracksRecorderIntegration saveIntegration(User user, String baseUrl, S deviceId.trim(), enabled, authUsername, - authPassword + authPassword, + device.id() ); return jdbcService.save(user, newIntegration); } @@ -195,9 +216,16 @@ public void loadHistoricalData(User user) { if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) { throw new IllegalStateException("No enabled OwnTracks Recorder integration found for user"); } - + OwnTracksRecorderIntegration integration = integrationOpt.get(); - + Device device = this.deviceJdbcService.find(user, integration.getReittiDeviceId()).orElseThrow(); + String partitionKey = UUID.randomUUID().toString(); + this.stagingService.ensurePartitionExists(partitionKey); + UUID parentJobId = jobSchedulingService.createParentJob( + user, + JobType.OWNTRACKS_IMPORT, + "Owntracks History Import" + ); try { // First, fetch all recs for the user Set availableMonths = fetchAvailableMonths(integration); @@ -230,7 +258,7 @@ public void loadHistoricalData(User user) { } if (!validPoints.isEmpty()) { - importBatchProcessor.processBatch(user, validPoints); + this.stagingService.insertBatch(partitionKey, user, device, validPoints); totalLocationPoints += validPoints.size(); logger.debug("Loaded {} location points for user {} from month {}", validPoints.size(), user.getUsername(), month); @@ -239,9 +267,17 @@ public void loadHistoricalData(User user) { } catch (Exception e) { logger.error("Failed to load data for user {} from month {}: {}", user.getUsername(), month, e.getMessage(), e); - // Continue with other months } } + + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.OWNTRACKS_IMPORT) + .friendlyName("Owntracks History Import") + .build(); + jobSchedulingService.enqueueTask(promotionTask, + new PromotionJobHandler.PromotionTaskData(user, device, partitionKey, true).withParentJobId(parentJobId), + metadata); logger.info("Loaded {} total historical location points for user {}", totalLocationPoints, user.getUsername()); diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java index 22a5a8fe1..0006f9063 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java @@ -1,6 +1,8 @@ package com.dedicatedcode.reitti.service.integration; import com.dedicatedcode.reitti.dto.*; +import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry; +import com.dedicatedcode.reitti.dto.timeline.UserTimelineData; import com.dedicatedcode.reitti.model.geo.GeoPoint; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.integration.ReittiIntegration; @@ -61,6 +63,26 @@ public ReittiIntegrationService(@Value("${reitti.server.advertise-uri}") String this.avatarService = avatarService; } + public List getUserData(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) { + return this.jdbcService + .findAllByUser(user) + .stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus())) + .map(integration -> { + + log.debug("Fetching user timeline data range for [{}] from {} to {}", integration, startDate, endDate); + try { + return buildUserTimelineData(startDate, endDate, userTimezone, integration, handleRemoteUser(integration), Collections.emptyList()); + } catch (RequestFailedException e) { + log.error("couldn't fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); + } catch (RequestTemporaryFailedException e) { + log.warn("couldn't temporarily fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.RECOVERABLE).withLastUsed(LocalDateTime.now())); + } + return null; + }).toList(); + } + public List getTimelineDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) { return this.jdbcService .findAllByUser(user) @@ -70,22 +92,9 @@ public List getTimelineDataRange(User user, LocalDate startDat log.debug("Fetching user timeline data range for [{}] from {} to {}", integration, startDate, endDate); try { RemoteUser remoteUser = handleRemoteUser(integration); - List timelineEntries = loadTimeLineEntriesRange(integration, startDate, endDate, userTimezone); - integration = update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now())); - - String mapMetaDataUrl = String.format("/reitti-integration/metadata/%d?start=%s&end=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone); - String mapStreamDataUrl = String.format("/reitti-integration/stream/%d?start=%s&end=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone); - - return new UserTimelineData("remote:" + integration.getId(), - remoteUser.getDisplayName(), - this.avatarService.generateInitials(remoteUser.getDisplayName()), - "/reitti-integration/avatar/" + integration.getId(), - integration.getColor(), - timelineEntries, - null, - String.format("/reitti-integration/visits/%d?startDate=%s&endDate=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone), - mapMetaDataUrl, - mapStreamDataUrl); + List timelineEntries = loadTimeLineEntriesRange(integration, startDate, endDate, userTimezone); + + return buildUserTimelineData(startDate, endDate, userTimezone, integration, remoteUser, timelineEntries); } catch (RequestFailedException e) { log.error("couldn't fetch user info for [{}]", integration, e); update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); @@ -97,6 +106,7 @@ public List getTimelineDataRange(User user, LocalDate startDat }).toList(); } + public ReittiRemoteInfo getInfo(ReittiIntegration integration) throws RequestFailedException, RequestTemporaryFailedException { return getInfo(integration.getUrl(), integration.getToken()); } @@ -132,7 +142,7 @@ public ReittiRemoteInfo getInfo(String url, String token) throws RequestFailedEx } } - public Optional getAvatar(User user, Long integrationId) { + public Optional getAvatar(Long integrationId) { Map result; try { result = jdbcTemplate.queryForMap( @@ -357,7 +367,7 @@ private ReittiIntegration update(ReittiIntegration integration) { return integration; } - private List loadTimeLineEntriesRange(ReittiIntegration integration, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException { + private List loadTimeLineEntriesRange(ReittiIntegration integration, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException { HttpHeaders headers = new HttpHeaders(); headers.set("X-API-TOKEN", integration.getToken()); @@ -367,8 +377,8 @@ private List loadTimeLineEntriesRange(ReittiIntegration integrati integration.getUrl() + "api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}" : integration.getUrl() + "/api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}"; - ParameterizedTypeReference> typeRef = new ParameterizedTypeReference<>() {}; - ResponseEntity> remoteResponse = restTemplate.exchange( + ParameterizedTypeReference> typeRef = new ParameterizedTypeReference<>() {}; + ResponseEntity> remoteResponse = restTemplate.exchange( timelineUrl, HttpMethod.GET, entity, @@ -665,4 +675,24 @@ private LocationPoint parseLocationPoint(Object locationObj) { return locationPoint; } + + private UserTimelineData buildUserTimelineData(LocalDate startDate, LocalDate endDate, ZoneId userTimezone, ReittiIntegration integration, RemoteUser remoteUser, List timelineEntries) { + integration = update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now())); + + String mapMetaDataUrl = String.format("/reitti-integration/metadata/%d?start=%s&end=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone); + String mapStreamDataUrl = String.format("/reitti-integration/stream/%d?start=%s&end=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone); + + return new UserTimelineData("remote:" + integration.getId(), + remoteUser.getDisplayName(), + this.avatarService.generateInitials(remoteUser.getDisplayName()), + "/reitti-integration/avatar/" + integration.getId(), + integration.getColor(), + timelineEntries, + null, + String.format("/reitti-integration/visits/%d?startDate=%s&endDate=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone), + mapMetaDataUrl, + mapStreamDataUrl, + Collections.emptyList()); + } + } diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttIntegration.java b/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttIntegration.java index 2b7820510..7ea88ddde 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttIntegration.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttIntegration.java @@ -13,16 +13,17 @@ public class MqttIntegration { private final String password; private final PayloadType payloadType; private final boolean enabled; + private final Long deviceId; private final Instant createdAt; private final Instant updatedAt; private final Instant lastUsed; private final Long version; public static MqttIntegration empty() { - return new MqttIntegration(null, null, 0, false, null, null, null, null, null, false, null, null, null, null); + return new MqttIntegration(null, null, 0, false, null, null, null, null, null, false, null, null, null, null, null); } - public MqttIntegration(Long id, String host, int port, boolean useTLS, String identifier, String topic, String username, String password, PayloadType payloadType, boolean enabled, Instant createdAt, Instant updatedAt, Instant lastUsed, Long version) { + public MqttIntegration(Long id, String host, int port, boolean useTLS, String identifier, String topic, String username, String password, PayloadType payloadType, boolean enabled, Long deviceId, Instant createdAt, Instant updatedAt, Instant lastUsed, Long version) { this.id = id; this.host = host; this.port = port; @@ -33,6 +34,7 @@ public MqttIntegration(Long id, String host, int port, boolean useTLS, String id this.password = password; this.payloadType = payloadType; this.enabled = enabled; + this.deviceId = deviceId; this.createdAt = createdAt; this.updatedAt = updatedAt; this.lastUsed = lastUsed; @@ -75,6 +77,10 @@ public PayloadType getPayloadType() { return payloadType; } + public Long getDeviceId() { + return deviceId; + } + public boolean isEnabled() { return enabled; } @@ -96,58 +102,62 @@ public Instant getLastUsed() { } public MqttIntegration withId(Long id) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withHost(String host) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withPort(int port) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withUseTLS(boolean useTLS) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withIdentifier(String identifier) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withTopic(String topic) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withUsername(String username) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withPassword(String password) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withPayloadType(PayloadType payloadType) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withEnabled(boolean enabled) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withCreatedAt(Instant createdAt) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withUpdatedAt(Instant updatedAt) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withLastUsed(Instant lastUsed) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); + } + + public MqttIntegration withDeviceId(Long deviceId) { + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } public MqttIntegration withVersion(Long version) { - return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, createdAt, updatedAt, lastUsed, version); + return new MqttIntegration(id, host, port, useTLS, identifier, topic, username, password, payloadType, enabled, deviceId, createdAt, updatedAt, lastUsed, version); } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttPayloadProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttPayloadProcessor.java index 22e724377..4e4d91456 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttPayloadProcessor.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/MqttPayloadProcessor.java @@ -1,8 +1,9 @@ package com.dedicatedcode.reitti.service.integration.mqtt; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; public interface MqttPayloadProcessor { PayloadType getSupportedType(); - void process(User user, byte[] payload); + void process(User user, Device device, byte[] payload); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/OwnTracksProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/OwnTracksProcessor.java index 459971b30..0877c6e9a 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/OwnTracksProcessor.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/mqtt/OwnTracksProcessor.java @@ -2,6 +2,7 @@ import com.dedicatedcode.reitti.dto.LocationPoint; import com.dedicatedcode.reitti.dto.OwntracksLocationRequest; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.service.LocationBatchingService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,7 +29,7 @@ public PayloadType getSupportedType() { } @Override - public void process(User user, byte[] payload) { + public void process(User user, Device device, byte[] payload) { String json = new String(payload, StandardCharsets.UTF_8); logger.info("Processing OwnTracks data for user {}: {}", user, json); @@ -46,7 +47,7 @@ public void process(User user, byte[] payload) { return; } - this.locationBatchingService.addLocationPoint(user, locationPoint); + this.locationBatchingService.addLocationPoint(user, device, locationPoint); logger.debug("Successfully received and queued Owntracks location point for user {}", user.getUsername()); diff --git a/src/main/java/com/dedicatedcode/reitti/service/jobs/JobInfo.java b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobInfo.java new file mode 100644 index 000000000..5063f534a --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobInfo.java @@ -0,0 +1,58 @@ +package com.dedicatedcode.reitti.service.jobs; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record JobInfo( + UUID id, + String name, + String description, + JobState state, + LocalDateTime enqueuedAt, + LocalDateTime scheduledAt, + LocalDateTime processingAt, + LocalDateTime finishedAt, + boolean canCancel, + List children, + long completedChildren, + long totalChildren, + Long durationSeconds, + float progressPercentValue, + String progressMessage +) { + public String progressText() { + if (progressMessage != null) { + return progressMessage; + } else if (totalChildren > 0) { + return completedChildren + " / " + totalChildren + " child jobs"; + } + return null; + } + + public int progressPercent() { + if (totalChildren > 0) { + return (int) ((completedChildren * 100) / totalChildren); + } else { + return (int) (progressPercentValue); + } + } + + public String formattedDuration() { + if (durationSeconds == null || durationSeconds == 0) { + return null; + } + long hours = durationSeconds / 3600; + long minutes = (durationSeconds % 3600) / 60; + long seconds = durationSeconds % 60; + + if (hours > 0) { + return String.format("%dh %dm %ds", hours, minutes, seconds); + } else if (minutes > 0) { + return String.format("%dm %ds", minutes, seconds); + } else { + return String.format("%ds", seconds); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/jobs/JobMetadataCleanupService.java b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobMetadataCleanupService.java new file mode 100644 index 000000000..9c44052b6 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobMetadataCleanupService.java @@ -0,0 +1,36 @@ +package com.dedicatedcode.reitti.service.jobs; + +import com.dedicatedcode.reitti.repository.JobMetadataRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Service +public class JobMetadataCleanupService { + + private static final Logger log = LoggerFactory.getLogger(JobMetadataCleanupService.class); + + private final JobMetadataRepository jobMetadataRepository; + private final int maxAgeHours; + + public JobMetadataCleanupService(JobMetadataRepository jobMetadataRepository, + @Value("${reitti.jobs.cleanup.max-age-hours:24}") int maxAgeHours) { + this.jobMetadataRepository = jobMetadataRepository; + this.maxAgeHours = maxAgeHours; + } + + @Scheduled(cron = "${reitti.jobs.cleanup.cron:0 0 4 * * ?}") + @Transactional + public void cleanUpOldJobs() { + Instant cutoff = Instant.now().minus(maxAgeHours, ChronoUnit.HOURS); + int deleted = jobMetadataRepository.deleteOlderThan(cutoff); + log.info("Cleaned up {} job metadata entries older than {} hours", deleted, maxAgeHours); + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/jobs/JobSchedulingService.java b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobSchedulingService.java new file mode 100644 index 000000000..69cd930bc --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobSchedulingService.java @@ -0,0 +1,166 @@ +package com.dedicatedcode.reitti.service.jobs; + +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.JobMetadataRepository; +import com.dedicatedcode.reitti.service.JobContext; +import com.github.kagkarlsson.scheduler.CurrentlyExecuting; +import com.github.kagkarlsson.scheduler.Scheduler; +import com.github.kagkarlsson.scheduler.event.SchedulerListener; +import com.github.kagkarlsson.scheduler.task.Execution; +import com.github.kagkarlsson.scheduler.task.ExecutionComplete; +import com.github.kagkarlsson.scheduler.task.Task; +import com.github.kagkarlsson.scheduler.task.TaskInstanceId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Service +public class JobSchedulingService implements SchedulerListener { + private static final Logger log = LoggerFactory.getLogger(JobSchedulingService.class); + + private final ObjectProvider jobScheduler; + private final JobMetadataRepository jobMetadataRepository; + + public JobSchedulingService(ObjectProvider jobScheduler, JobMetadataRepository jobMetadataRepository) { + this.jobScheduler = jobScheduler; + this.jobMetadataRepository = jobMetadataRepository; + } + + public > void scheduleTask(Task task, T data, Instant scheduledAt, Metadata meta) { + scheduleTask(UUID.randomUUID(), task, data, scheduledAt, meta); + } + + private > void scheduleTask(UUID jobId, Task task, T data, Instant scheduledAt, Metadata meta) { + Instant now = Instant.now(); + + JobState state = scheduledAt.isAfter(now) ? JobState.AWAITING : JobState.CREATED; + jobMetadataRepository.insert(jobId, meta.user, task.getTaskName(), meta.jobType, meta.friendlyName, state, now, scheduledAt, data.getParentJobId()); + + T updatedData = data.withJobId(jobId); + if (data.getParentJobId() != null) { + updatedData = updatedData.withParentJobId(data.getParentJobId()); + } + jobScheduler.getObject().schedule(task.instance(jobId.toString(), updatedData), scheduledAt); + } + + public > void enqueueTask(Task task, T data, Metadata meta) { + scheduleTask(task, data, Instant.now(), meta); + } + + public void cancel(UUID jobId) { + Optional meta = jobMetadataRepository.findById(jobId); + if (meta.isPresent() && meta.get().getTaskId() != null) { + jobScheduler.getObject().cancel( + TaskInstanceId.of(meta.get().getTaskId(), jobId.toString())); + } + jobMetadataRepository.delete(jobId); + } + + public UUID createParentJob(User user, JobType jobType, String friendlyName) { + UUID parentJobId = UUID.randomUUID(); + Instant now = Instant.now(); + + jobMetadataRepository.insert( + parentJobId, + user, + null, + jobType, + friendlyName, + JobState.AWAITING, + now, + now, + null + ); + + return parentJobId; + } + + @Override + public void onExecutionScheduled(TaskInstanceId taskInstanceId, Instant executionTime) { + UUID jobId = UUID.fromString(taskInstanceId.getId()); + this.jobMetadataRepository.updateState(jobId, JobState.AWAITING, Instant.now()); + log.trace("Job with ID {} is in the state of {}", jobId, JobState.AWAITING); + } + + @Override + public void onExecutionStart(CurrentlyExecuting currentlyExecuting) { + UUID jobId = UUID.fromString(currentlyExecuting.getTaskInstance().getId()); + this.jobMetadataRepository.updateState(jobId, JobState.RUNNING, Instant.now()); + log.trace("Job with ID {} is now in the state of {}", jobId, JobState.RUNNING); + Optional metadata = jobMetadataRepository.findById(jobId); + if (metadata.isPresent() && metadata.get().getParentJobId() != null) { + jobMetadataRepository.updateParentJobState(metadata.get().getParentJobId(), JobState.RUNNING); + } + } + + @Override + public void onExecutionComplete(ExecutionComplete executionComplete) { + UUID jobId = UUID.fromString(executionComplete.getExecution().getId()); + JobState state = executionComplete.getResult() == ExecutionComplete.Result.OK ? JobState.COMPLETED : JobState.FAILED; + this.jobMetadataRepository.updateState(jobId, state, Instant.now()); + if (state == JobState.FAILED) { + log.error("Job with ID {} failed", jobId, executionComplete.getCause().orElseThrow()); + } else { + log.trace("Job with ID {} is now in the state of {}", jobId, state); + } + Optional metadata = jobMetadataRepository.findById(jobId); + if (metadata.isPresent() && metadata.get().getParentJobId() != null) { + jobMetadataRepository.updateParentJobState(metadata.get().getParentJobId(), state); + } + } + + @Override + public void onExecutionDead(Execution execution) { + + } + + @Override + public void onExecutionFailedHeartbeat(CurrentlyExecuting currentlyExecuting) { + + } + + @Override + public void onSchedulerEvent(SchedulerEventType type) { + + } + + @Override + public void onCandidateEvent(CandidateEventType type) { + + } + + public record Metadata(User user, JobType jobType, String friendlyName) { + public static class Builder { + private User user; + private JobType jobType; + private String friendlyName; + public Builder user(User user) { + this.user = user; + return this; + } + + public Builder jobType(JobType jobType) { + this.jobType = jobType; + return this; + } + + public Builder friendlyName(String friendlyName) { + this.friendlyName = friendlyName; + return this; + } + + public Metadata build() { + return new Metadata(user, jobType, friendlyName); + } + } + + public static Builder builder() { + return new Builder(); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/jobs/JobState.java b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobState.java new file mode 100644 index 000000000..2858af10e --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobState.java @@ -0,0 +1,10 @@ +package com.dedicatedcode.reitti.service.jobs; + +public enum JobState { + PREPARING, + AWAITING, + RUNNING, + COMPLETED, + FAILED, + CREATED, CANCELLED +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/jobs/JobType.java b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobType.java new file mode 100644 index 000000000..aff5a8c58 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/jobs/JobType.java @@ -0,0 +1,18 @@ +package com.dedicatedcode.reitti.service.jobs; + +public enum JobType { + GPS_IMPORT, + GPS_INGESTION, + GPX_IMPORT, + GOOGLE_TIMELINE_IMPORT, + GEOJSON_IMPORT, + OWNTRACKS_IMPORT, + VISIT_TRIP_DETECTION, + SSE_EVENT, + REVERSE_GEOCODE, + LOCATION_PROCESSING, + DATA_RECALCULATION, + RECALCULATION, + LOCATION_DATA_CLEANUP, + MANUAL_MODIFICATION, TIMELINE_STITCHING, +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/jobs/VisitSensitivityConfigurationRecalculationTask.java b/src/main/java/com/dedicatedcode/reitti/service/jobs/VisitSensitivityConfigurationRecalculationTask.java new file mode 100644 index 000000000..a30a77371 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/jobs/VisitSensitivityConfigurationRecalculationTask.java @@ -0,0 +1,91 @@ +package com.dedicatedcode.reitti.service.jobs; + +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; +import com.dedicatedcode.reitti.model.processing.RecalculationState; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.*; +import com.dedicatedcode.reitti.service.JobContext; +import com.github.kagkarlsson.scheduler.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +public class VisitSensitivityConfigurationRecalculationTask { + private static final Logger log = LoggerFactory.getLogger(VisitSensitivityConfigurationRecalculationTask.class); + private final VisitDetectionParametersJdbcService configurationService; + private final JobSchedulingService jobSchedulingService; + private final JobMetadataRepository jobMetadataRepository; + private final Task processingEventTask; + private final TripJdbcService tripJdbcService; + private final ProcessedVisitJdbcService processedVisitJdbcService; + private final SignificantPlaceJdbcService significantPlaceJdbcService; + private final RawLocationPointJdbcService rawLocationPointJdbcService; + + public VisitSensitivityConfigurationRecalculationTask(VisitDetectionParametersJdbcService configurationService, + JobSchedulingService jobSchedulingService, + JobMetadataRepository jobMetadataRepository, + Task processingEventTask, + TripJdbcService tripJdbcService, + ProcessedVisitJdbcService processedVisitJdbcService, + SignificantPlaceJdbcService significantPlaceJdbcService, RawLocationPointJdbcService rawLocationPointJdbcService) { + this.configurationService = configurationService; + this.jobSchedulingService = jobSchedulingService; + this.jobMetadataRepository = jobMetadataRepository; + this.processingEventTask = processingEventTask; + this.tripJdbcService = tripJdbcService; + this.processedVisitJdbcService = processedVisitJdbcService; + this.significantPlaceJdbcService = significantPlaceJdbcService; + this.rawLocationPointJdbcService = rawLocationPointJdbcService; + } + + public void execute(TaskData taskData) { + User user = taskData.user; + log.debug("Executing DataRecalculationJob for [{}]", user); + try { + this.jobMetadataRepository.updateProgress(taskData.getJobId(), 1, 5, "Deleting Trips ..."); + tripJdbcService.deleteAllForUser(user); + this.jobMetadataRepository.updateProgress(taskData.getJobId(), 2, 5, "Deleting Visits ..."); + processedVisitJdbcService.deleteAllForUser(user); + this.jobMetadataRepository.updateProgress(taskData.getJobId(), 3, 5, "Deleting Places ..."); + significantPlaceJdbcService.deleteForUser(user); + this.jobMetadataRepository.updateProgress(taskData.getJobId(), 4, 5, "Flag points as unprocessed ..."); + rawLocationPointJdbcService.markAllAsUnprocessedForUser(user); + this.configurationService.findAllConfigurationsForUser(user) + .forEach(config -> this.configurationService.updateConfiguration(config.withRecalculationState(RecalculationState.DONE))); + log.debug("Starting recalculation of all configurations"); + this.jobMetadataRepository.updateProgress(taskData.getJobId(), 5, 5, "Starting recalculation ... "); + jobSchedulingService.enqueueTask(processingEventTask, + new TriggerProcessingEvent(user.getUsername(), null, null).withParentJobId(taskData.getJobId()), + new JobSchedulingService.Metadata(user, JobType.LOCATION_PROCESSING, "Processing location data ...")); + } catch (Exception e) { + log.error("Error clearing time range", e); + } + + } + public static class TaskData extends JobContext { + + public final User user; + + public TaskData(User user) { + this.user = user; + } + + private TaskData(User user, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + } + + @Override + public TaskData withJobId(UUID jobId) { + return new TaskData(user, jobId, parentJobId); + } + + @Override + public TaskData withParentJobId(UUID parentJobId) { + return new TaskData(user, jobId, parentJobId); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/AnomalyProcessingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/AnomalyProcessingService.java index 1766da2e3..d7ff13ed5 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/AnomalyProcessingService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/AnomalyProcessingService.java @@ -1,8 +1,10 @@ package com.dedicatedcode.reitti.service.processing; +import com.dedicatedcode.reitti.model.devices.Device; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -17,23 +19,24 @@ public class AnomalyProcessingService { private final GeoPointAnomalyFilter detector; private final GeoPointAnomalyFilterConfig config; - private final RawLocationPointJdbcService repository; + private final SourceLocationPointJdbcService repository; - public AnomalyProcessingService(GeoPointAnomalyFilter geoPointAnomalyFilter, GeoPointAnomalyFilterConfig config, RawLocationPointJdbcService repository) { + public AnomalyProcessingService(GeoPointAnomalyFilter geoPointAnomalyFilter, GeoPointAnomalyFilterConfig config, SourceLocationPointJdbcService repository) { this.detector = geoPointAnomalyFilter; this.config = config; this.repository = repository; } - public void processAndMarkAnomalies(User user, Instant start, Instant end) { + public TimeRange processAndMarkAnomalies(User user, Device device, Instant start, Instant end) { Instant startTime = start.minus(config.getHistoryLookback(), ChronoUnit.HOURS); Instant endTime = end.plus(config.getHistoryLookback(), ChronoUnit.HOURS); repository.resetInvalidStatus(user, startTime, endTime); - List pointsToCheck = repository.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startTime, endTime, false, false, true); + List pointsToCheck = repository.findByUserAndTimestampBetweenOrderByTimestampAsc(user, device, startTime, endTime, false, true); logger.debug("Found {} points to check for user {}", pointsToCheck.size(), user.getUsername()); - List anomalousPoints = detector.detectAnomalies(pointsToCheck); + List anomalousPoints = detector.detectAnomalies(pointsToCheck); repository.bulkUpdateInvalidStatus(anomalousPoints); logger.info("Marked {} points as invalid for user {}", anomalousPoints.size(), user.getUsername()); + return new TimeRange(startTime, endTime); } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/DeviceTimeRange.java b/src/main/java/com/dedicatedcode/reitti/service/processing/DeviceTimeRange.java new file mode 100644 index 000000000..c9b324612 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/DeviceTimeRange.java @@ -0,0 +1,4 @@ +package com.dedicatedcode.reitti.service.processing; + +public record DeviceTimeRange(Long deviceId, TimeRange timeRange) { +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/ExcessDensityHandler.java b/src/main/java/com/dedicatedcode/reitti/service/processing/ExcessDensityHandler.java new file mode 100644 index 000000000..33010827a --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/ExcessDensityHandler.java @@ -0,0 +1,101 @@ +package com.dedicatedcode.reitti.service.processing; + +import com.dedicatedcode.reitti.config.LocationDensityConfig; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; +import com.dedicatedcode.reitti.model.processing.DetectionParameter; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService; +import com.dedicatedcode.reitti.service.VisitDetectionParametersService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.*; + +@Service +public class ExcessDensityHandler { + + private static final Logger logger = LoggerFactory.getLogger(ExcessDensityHandler.class); + + private final LocationDensityConfig config; + private final VisitDetectionParametersService visitDetectionParametersService; + private final SourceLocationPointJdbcService rawLocationPointService; + + public ExcessDensityHandler(LocationDensityConfig config, VisitDetectionParametersService visitDetectionParametersService, + SourceLocationPointJdbcService rawLocationPointService) { + this.config = config; + this.visitDetectionParametersService = visitDetectionParametersService; + this.rawLocationPointService = rawLocationPointService; + } + + public TimeRange handleExcess(User user, Device device, TimeRange inputRange) { + DetectionParameter detectionParams = visitDetectionParametersService.getCurrentConfiguration(user, inputRange.start()); + DetectionParameter.LocationDensity densityConfig = detectionParams.getLocationDensity(); + + // Step 2: Expand the time range by the interpolation window to catch boundary gaps + long maxInterpolationGapMinutes = densityConfig.getMaxInterpolationGapMinutes(); + Duration window = Duration.ofMinutes(maxInterpolationGapMinutes); + TimeRange expandedRange = new TimeRange( + inputRange.start().minus(window), + inputRange.end().plus(window) + ); + List points = rawLocationPointService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, device, expandedRange.start(), expandedRange.end(), false, true); + if (points.size() < 2) { + return TimeRange.empty(); + } + + int toleranceSeconds = config.getToleranceSeconds() - 1; + Set pointsToIgnore = new LinkedHashSet<>(); + Set alreadyConsidered = new HashSet<>(); + + for (int i = 0; i < points.size() - 1; i++) { + SourceLocationPoint current = points.get(i); + SourceLocationPoint next = points.get(i + 1); + + // Safety filters + if (current.getId() == null || next.getId() == null) continue; + if (current.getStatus() != SourceLocationPoint.Status.VALID || next.getStatus() != SourceLocationPoint.Status.VALID) continue; + if (alreadyConsidered.contains(current.getId()) || alreadyConsidered.contains(next.getId())) continue; + + long timeDiff = Duration.between(current.getTimestamp(), next.getTimestamp()).getSeconds(); + if (timeDiff < toleranceSeconds) { + SourceLocationPoint toIgnore = selectPointToIgnore(current, next); + if (toIgnore.getId() != null) { + pointsToIgnore.add(toIgnore.getId()); + alreadyConsidered.add(toIgnore.getId()); + logger.trace("Marking point {} as ignored due to excess density", toIgnore.getId()); + } + } + } + + if (!pointsToIgnore.isEmpty()) { + rawLocationPointService.bulkUpdateIgnoredStatus(user, new ArrayList<>(pointsToIgnore)); + logger.debug("Marked {} points as ignored for user {}", pointsToIgnore.size(), user.getUsername()); + } + return expandedRange; + } + + // The selection logic is unchanged from the original, kept here for completeness. + private SourceLocationPoint selectPointToIgnore(SourceLocationPoint p1, SourceLocationPoint p2) { + Double acc1 = p1.getAccuracyMeters(); + Double acc2 = p2.getAccuracyMeters(); + if (acc1 != null && acc2 != null) { + if (!acc1.equals(acc2)) return acc1 < acc2 ? p2 : p1; + } else if (acc1 != null) { + return p2; + } else if (acc2 != null) { + return p1; + } + + int timeCmp = p1.getTimestamp().compareTo(p2.getTimestamp()); + if (timeCmp != 0) return timeCmp < 0 ? p2 : p1; + + int latCmp = Double.compare(p1.getGeom().latitude(), p2.getGeom().latitude()); + if (latCmp != 0) return latCmp < 0 ? p2 : p1; + + int lonCmp = Double.compare(p1.getGeom().longitude(), p2.getGeom().longitude()); + return lonCmp < 0 ? p2 : p1; + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/GeoPointAnomalyFilter.java b/src/main/java/com/dedicatedcode/reitti/service/processing/GeoPointAnomalyFilter.java index 7e6a046c1..925e1cf26 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/GeoPointAnomalyFilter.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/GeoPointAnomalyFilter.java @@ -2,6 +2,7 @@ import com.dedicatedcode.reitti.model.geo.GeoUtils; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.geo.SourceLocationPoint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -19,13 +20,13 @@ public GeoPointAnomalyFilter(GeoPointAnomalyFilterConfig config) { this.config = config; } - public List detectAnomalies(List pointsToCheck) { + public List detectAnomalies(List pointsToCheck) { if (pointsToCheck == null || pointsToCheck.size() < 2) { return new ArrayList<>(); } - List sortedPoints = pointsToCheck.stream() - .sorted(Comparator.comparing(RawLocationPoint::getTimestamp)) + List sortedPoints = pointsToCheck.stream() + .sorted(Comparator.comparing(SourceLocationPoint::getTimestamp)) .collect(Collectors.toList()); Set anomalyIds = new HashSet<>(); @@ -41,14 +42,14 @@ public List detectAnomalies(List pointsToChe .collect(Collectors.toList()); } - private Set filterByAccuracy(List points) { + private Set filterByAccuracy(List points) { return points.stream() .filter(p -> p.getAccuracyMeters() > config.getMaxAccuracyMeters()) - .map(RawLocationPoint::getId) + .map(SourceLocationPoint::getId) .collect(Collectors.toSet()); } - private Set filterBySpeed(List points) { + private Set filterBySpeed(List points) { Set anomalies = new HashSet<>(); if (points.size() < 2) return anomalies; @@ -100,7 +101,7 @@ private Set filterBySpeed(List points) { return anomalies; } - private double calculateSpeed(RawLocationPoint p1, RawLocationPoint p2) { + private double calculateSpeed(SourceLocationPoint p1, SourceLocationPoint p2) { if (p1.getTimestamp() == null || p2.getTimestamp() == null) { return -1; // Invalid } diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataCleanupJob.java b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataCleanupJob.java new file mode 100644 index 000000000..aa888cc7f --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataCleanupJob.java @@ -0,0 +1,127 @@ +package com.dedicatedcode.reitti.service.processing; + +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.JobMetadataRepository; +import com.dedicatedcode.reitti.repository.UserJdbcService; +import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; +import com.dedicatedcode.reitti.service.JobContext; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.github.kagkarlsson.scheduler.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.UUID; + +import static com.dedicatedcode.reitti.service.jobs.JobType.VISIT_TRIP_DETECTION; + +@Component +public class LocationDataCleanupJob { + private static final Logger log = LoggerFactory.getLogger(LocationDataCleanupJob.class); + private final ExcessDensityHandler excessDensityHandler; + private final AnomalyProcessingService anomalyProcessingService; + private final UserSettingsJdbcService userSettingsJdbcService; + private final UserJdbcService userJdbcService; + private final JobSchedulingService jobScheduler; + private final Task updateCuratedTimelineTask; + private final JobMetadataRepository metadataRepository; + + public LocationDataCleanupJob(ExcessDensityHandler excessDensityHandler, + AnomalyProcessingService anomalyProcessingService, + UserSettingsJdbcService userSettingsJdbcService, + UserJdbcService userJdbcService, + JobSchedulingService jobScheduler, + Task updateCuratedTimelineTask, JobMetadataRepository metadataRepository) { + this.excessDensityHandler = excessDensityHandler; + this.anomalyProcessingService = anomalyProcessingService; + this.userSettingsJdbcService = userSettingsJdbcService; + this.userJdbcService = userJdbcService; + this.jobScheduler = jobScheduler; + this.updateCuratedTimelineTask = updateCuratedTimelineTask; + this.metadataRepository = metadataRepository; + } + + public void execute(TaskData data) { + UUID jobId = data.getJobId(); + User user = data.getUser(); + Device device = data.getDevice(); + Instant start = data.getStart(); + Instant end = data.getEnd(); + log.debug("Starting LocationDataCleanupJob for user [{}] and device [{}] between {} and {}", user, device, start, end); + this.metadataRepository.updateProgress(jobId, 0,4, "Anomaly processing started ..."); + TimeRange processedTimeRange = anomalyProcessingService.processAndMarkAnomalies(user, device, start, end); + this.metadataRepository.updateProgress(jobId, 1,4, "Density normalization started ..."); + TimeRange densityTimeRange = excessDensityHandler.handleExcess(user, device, new TimeRange(start, end)); + this.metadataRepository.updateProgress(jobId, 2,4, "Update user data started ..."); + this.userSettingsJdbcService.updateNewestData(user, end); + this.userJdbcService.setLastDataModificationAt(user, Instant.now()); + this.metadataRepository.updateProgress(jobId, 3,4, "Schedule processing events started ..."); + if (device.defaultDevice()) { + jobScheduler.enqueueTask(updateCuratedTimelineTask, + new UpdateCuratedTimelineJob.TaskData(user, device, processedTimeRange.extend(densityTimeRange)), + JobSchedulingService.Metadata.builder().jobType(VISIT_TRIP_DETECTION) + .user(user) + .friendlyName("Detect Visits and Trips").build() + ); + } + + this.metadataRepository.updateProgress(jobId, 4,4, "Finished"); + } + + public static final class TaskData extends JobContext { + private final User user; + private final Device device; + private final Instant start; + private final Instant end; + + public TaskData(User user, Device device, Instant start, Instant end) { + this(user, device, start, end, null, null); + } + + public TaskData(User user, Device device, Instant start, Instant end, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + this.device = device; + this.start = start; + this.end = end; + } + + public User getUser() { + return user; + } + + public Device getDevice() { + return device; + } + + public Instant getStart() { + return start; + } + + public Instant getEnd() { + return end; + } + + @Override + public TaskData withJobId(UUID jobId) { + return new TaskData(user, device, start, end, jobId, parentJobId); + } + + @Override + public TaskData withParentJobId(UUID parentJobId) { + return new TaskData(user, device, start, end, jobId, parentJobId); + } + + @Override + public String toString() { + return "TaskData[" + + "user=" + user + ", " + + "device=" + device + ", " + + "start=" + start + ", " + + "end=" + end + "]"; + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataDensityNormalizer.java b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataDensityNormalizer.java deleted file mode 100644 index e011f5d42..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataDensityNormalizer.java +++ /dev/null @@ -1,272 +0,0 @@ -package com.dedicatedcode.reitti.service.processing; - -import com.dedicatedcode.reitti.config.LocationDensityConfig; -import com.dedicatedcode.reitti.dto.LocationPoint; -import com.dedicatedcode.reitti.model.geo.RawLocationPoint; -import com.dedicatedcode.reitti.model.processing.DetectionParameter; -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; -import com.dedicatedcode.reitti.service.VisitDetectionParametersService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -@Service -public class LocationDataDensityNormalizer { - - private static final Logger logger = LoggerFactory.getLogger(LocationDataDensityNormalizer.class); - - private final LocationDensityConfig config; - private final RawLocationPointJdbcService rawLocationPointService; - private final SyntheticLocationPointGenerator syntheticGenerator; - private final VisitDetectionParametersService visitDetectionParametersService; - private final ConcurrentHashMap userLocks = new ConcurrentHashMap<>(); - - @Autowired - public LocationDataDensityNormalizer( - LocationDensityConfig config, - RawLocationPointJdbcService rawLocationPointService, - SyntheticLocationPointGenerator syntheticGenerator, - VisitDetectionParametersService visitDetectionParametersService) { - this.config = config; - this.rawLocationPointService = rawLocationPointService; - this.syntheticGenerator = syntheticGenerator; - this.visitDetectionParametersService = visitDetectionParametersService; - } - - public void normalize(User user, List newPoints) { - if (newPoints == null || newPoints.isEmpty()) { - logger.trace("No points to normalize for user {}", user.getUsername()); - return; - } - - ReentrantLock userLock = userLocks.computeIfAbsent(user.getUsername(), _ -> new ReentrantLock()); - - userLock.lock(); - try { - logger.debug("Starting batch density normalization for {} points for user {}", - newPoints.size(), user.getUsername()); - - // Step 1: Compute the time range that encompasses all new points - TimeRange inputRange = computeTimeRange(newPoints); - - // Step 2: Get detection parameters (use the earliest point's time for config lookup) - DetectionParameter detectionParams = visitDetectionParametersService.getCurrentConfiguration(user, inputRange.start()); - DetectionParameter.LocationDensity densityConfig = detectionParams.getLocationDensity(); - - // Step 3: Expand the time range by the interpolation window to catch boundary gaps - long maxInterpolationGapMinutes = densityConfig.getMaxInterpolationGapMinutes(); - Duration window = Duration.ofMinutes(maxInterpolationGapMinutes); - TimeRange expandedRange = new TimeRange( - inputRange.start().minus(window), - inputRange.end().plus(window) - ); - // Step 4: Delete all synthetic points in the expanded range - rawLocationPointService.deleteSyntheticPointsInRange(user, expandedRange.start(), expandedRange.end()); - - // Step 5: Fetch all existing points in the expanded range (single DB query) - List existingPoints = rawLocationPointService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, expandedRange.start().minus(maxInterpolationGapMinutes, ChronoUnit.MINUTES), expandedRange.end().plus(maxInterpolationGapMinutes, ChronoUnit.MINUTES)); - - logger.debug("Found {} existing points in expanded range [{} - {}]", existingPoints.size(), expandedRange.start(), expandedRange.end()); - - // Step 7: Sort deterministically by timestamp, then by ID (for repeatability) - existingPoints.sort(Comparator - .comparing(RawLocationPoint::getTimestamp) - .thenComparing(p -> p.getGeom().latitude()) - .thenComparing(p -> p.getGeom().longitude()) - .thenComparing(RawLocationPoint::isSynthetic)); - - logger.trace("Processing {} total points after merge", existingPoints.size()); - - // Step 8: Process gaps (generate synthetic points) - processGaps(user, existingPoints, densityConfig); - handleExcessDensity(user, existingPoints); - logger.debug("Completed batch density normalization for user {}", user.getUsername()); - - } catch (Exception e) { - logger.error("Error during batch density normalization for user {}: {}", - user.getUsername(), e.getMessage(), e); - } finally { - userLock.unlock(); - } - } - - /** - * Computes the minimal time range that encompasses all given points. - */ - private TimeRange computeTimeRange(List points) { - Instant minTime = null; - Instant maxTime = null; - - for (LocationPoint point : points) { - Instant timestamp = point.getTimestamp(); - if (minTime == null || timestamp.isBefore(minTime)) { - minTime = timestamp; - } - if (maxTime == null || timestamp.isAfter(maxTime)) { - maxTime = timestamp; - } - } - - return new TimeRange(minTime, maxTime); - } - - /** - * Processes gaps between points and generates synthetic points where needed. - * Only processes each gap once, regardless of how many input points touch it. - */ - private void processGaps( - User user, - List points, - DetectionParameter.LocationDensity densityConfig) { - - if (points.size() < 2) { - return; - } - - int gapThresholdSeconds = config.getGapThresholdSeconds(); - long maxInterpolationSeconds = densityConfig.getMaxInterpolationGapMinutes() * 60L; - - List allSyntheticPoints = new ArrayList<>(); - - for (int i = 0; i < points.size() - 1; i++) { - RawLocationPoint current = points.get(i); - RawLocationPoint next = points.get(i + 1); - - // Skip if either point is already ignored or synthetic - if (current.isIgnored() || next.isIgnored() || current.isSynthetic()) { - continue; - } - - long gapSeconds = Duration.between(current.getTimestamp(), next.getTimestamp()).getSeconds(); - - if (gapSeconds > gapThresholdSeconds && gapSeconds <= maxInterpolationSeconds) { - - List syntheticPoints = syntheticGenerator.generateSyntheticPoints( - current, - next, - config.getTargetPointsPerMinute(), - densityConfig.getMaxInterpolationDistanceMeters() - ); - - logger.trace("Found gap of {} seconds between {} and {} -> created {} synthetic points between them", - gapSeconds, current.getTimestamp(), next.getTimestamp(), syntheticPoints.size()); - - allSyntheticPoints.addAll(syntheticPoints); - } - } - - if (!allSyntheticPoints.isEmpty()) { - int inserted = rawLocationPointService.bulkInsertSynthetic(user, allSyntheticPoints); - logger.debug("Inserted {} synthetic points for user {}", inserted, user.getUsername()); - } - } - - /** - * Handles excess density by marking redundant points as ignored. - * Uses deterministic rules for selecting which point to ignore. - */ - private void handleExcessDensity(User user, List points) { - if (points.size() < 2) { - return; - } - - int toleranceSeconds = config.getToleranceSeconds() - 1; - Set pointsToIgnore = new LinkedHashSet<>(); // Preserve order for debugging - Set alreadyConsidered = new HashSet<>(); - - for (int i = 0; i < points.size() - 1; i++) { - RawLocationPoint current = points.get(i); - RawLocationPoint next = points.get(i + 1); - - // Skip points without IDs (not persisted) or already ignored - if (current.getId() == null || next.getId() == null) { - continue; - } - // Skip synthetic points - if (current.isSynthetic() || next.isSynthetic()) { - continue; - } - if (current.isIgnored() || next.isIgnored()) { - continue; - } - if (alreadyConsidered.contains(current.getId()) || alreadyConsidered.contains(next.getId())) { - continue; - } - - long timeDiff = Duration.between(current.getTimestamp(), next.getTimestamp()).getSeconds(); - - if (timeDiff < toleranceSeconds) { - RawLocationPoint toIgnore = selectPointToIgnore(current, next); - - if (toIgnore != null && toIgnore.getId() != null) { - pointsToIgnore.add(toIgnore.getId()); - alreadyConsidered.add(toIgnore.getId()); - logger.trace("Marking point {} as ignored due to excess density", toIgnore.getId()); - } - } - } - - if (!pointsToIgnore.isEmpty()) { - rawLocationPointService.bulkUpdateIgnoredStatus(new ArrayList<>(pointsToIgnore), true); - logger.debug("Marked {} points as ignored for user {}", pointsToIgnore.size(), user.getUsername()); - } - } - - /** - * Selects which point to ignore when two points are too close together. - * Rules (in priority order): - * 1. Prefer real points over synthetic points - * 2. Prefer points with better accuracy (lower accuracy value) - * 3. Prefer points with accuracy info over those without - * 4. Prefer points with lower ID (earlier insertion = more authoritative) - */ - private RawLocationPoint selectPointToIgnore(RawLocationPoint point1, RawLocationPoint point2) { - // Rule 1: Never ignore real points if the other is synthetic - if (!point1.isSynthetic() && point2.isSynthetic()) { - return point2; - } - if (point1.isSynthetic() && !point2.isSynthetic()) { - return point1; - } - - // Rule 2 & 3: Prefer points with better accuracy - Double acc1 = point1.getAccuracyMeters(); - Double acc2 = point2.getAccuracyMeters(); - - if (acc1 != null && acc2 != null) { - if (!acc1.equals(acc2)) { - return acc1 < acc2 ? point2 : point1; - } - } else if (acc1 != null) { - return point2; - } else if (acc2 != null) { - return point1; - } - - int timestampCompare = point1.getTimestamp().compareTo(point2.getTimestamp()); - if (timestampCompare != 0) { - return timestampCompare < 0 ? point2 : point1; - } - - // Tiebreaker: use coordinates (immutable, deterministic) - int latCompare = Double.compare(point1.getGeom().latitude(), point2.getGeom().latitude()); - if (latCompare != 0) { - return latCompare < 0 ? point2 : point1; - } - - int lonCompare = Double.compare(point1.getGeom().longitude(), point2.getGeom().longitude()); - return lonCompare < 0 ? point2 : point1; - - } - - public record TimeRange(Instant start, Instant end) {} -} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java deleted file mode 100644 index d27a276e0..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationDataIngestPipeline.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.dedicatedcode.reitti.service.processing; - -import com.dedicatedcode.reitti.dto.LocationPoint; -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; -import com.dedicatedcode.reitti.repository.UserJdbcService; -import com.dedicatedcode.reitti.repository.UserSettingsJdbcService; -import com.dedicatedcode.reitti.service.UserNotificationService; -import jakarta.annotation.PreDestroy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; - -@Service -public class LocationDataIngestPipeline { - private static final Logger logger = LoggerFactory.getLogger(LocationDataIngestPipeline.class); - - private final AnomalyProcessingService anomalyProcessingService; - private final UserJdbcService userJdbcService; - private final RawLocationPointJdbcService rawLocationPointJdbcService; - private final UserSettingsJdbcService userSettingsJdbcService; - private final UserNotificationService userNotificationService; - private final LocationDataDensityNormalizer densityNormalizer; - - @Autowired - public LocationDataIngestPipeline(AnomalyProcessingService anomalyProcessingService, - UserJdbcService userJdbcService, - RawLocationPointJdbcService rawLocationPointJdbcService, - UserSettingsJdbcService userSettingsJdbcService, - UserNotificationService userNotificationService, - LocationDataDensityNormalizer densityNormalizer) { - this.anomalyProcessingService = anomalyProcessingService; - this.userJdbcService = userJdbcService; - this.rawLocationPointJdbcService = rawLocationPointJdbcService; - this.userSettingsJdbcService = userSettingsJdbcService; - this.userNotificationService = userNotificationService; - this.densityNormalizer = densityNormalizer; - } - - @PreDestroy - public void shutdown() { - } - - public void processLocationData(String username, List points) { - try { - long start = System.currentTimeMillis(); - logger.debug("starting processing"); - - Optional userOpt = userJdbcService.findByUsername(username); - - if (userOpt.isEmpty()) { - logger.warn("User not found for name: [{}]", username); - return; - } - - User user = userOpt.get(); - - List validPoints = points.stream().filter(LocationPoint::isValid).toList(); - int updatedRows = rawLocationPointJdbcService.bulkInsert(user, validPoints); - List timestamp = validPoints.stream().map(LocationPoint::getTimestamp).sorted().toList(); - anomalyProcessingService.processAndMarkAnomalies(user, timestamp.getFirst(), timestamp.getLast()); - - densityNormalizer.normalize(user, validPoints); - userSettingsJdbcService.updateNewestData(user, validPoints); - userNotificationService.newRawLocationData(user, validPoints); - logger.info("Finished storing and normalizing points [{}] for user [{}] in [{}]ms. Filtered out [{}] after database.", validPoints.size(), user, System.currentTimeMillis() - start, validPoints.size() - updatedRows); - } catch (Exception e) { - logger.error("Error during processing: ", e); - } - } - -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/LocationPointStagingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationPointStagingService.java new file mode 100644 index 000000000..bef96e55b --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/LocationPointStagingService.java @@ -0,0 +1,168 @@ +package com.dedicatedcode.reitti.service.processing; + +import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.List; + +@Service +public class LocationPointStagingService { + private static final Logger log = LoggerFactory.getLogger(LocationPointStagingService.class); + + private final JdbcTemplate jdbcTemplate; + private final int batchSize; + + public LocationPointStagingService(JdbcTemplate jdbcTemplate, + @Value("${reitti.import.batch-size:1000}") int batchSize) { + this.jdbcTemplate = jdbcTemplate; + this.batchSize = batchSize; + } + + public void ensurePartitionExists(String partitionKey) { + String tableName = getTableName(partitionKey); + String sql = String.format( + "CREATE TABLE IF NOT EXISTS %s PARTITION OF staging_location_points FOR VALUES IN ('%s')", + tableName, partitionKey + ); + this.jdbcTemplate.execute(sql); + log.debug("Ensured partition [{}] exists", tableName); + } + + @SuppressWarnings("SqlSourceToSinkFlow") + public void dropPartition(String partitionKey) { + String tableName = getTableName(partitionKey); + this.jdbcTemplate.execute("DROP TABLE IF EXISTS " + tableName); + log.debug("Dropped partition [{}]", tableName); + } + + public int getBatchSize() { + return batchSize; + } + + private String getTableName(String partitionKey) { + return "staged_" + partitionKey.toLowerCase().replace("-", "_").replace(".", "_"); + + } + + public void insertBatch(String partitionKey, User user, Device device, List batch) { + String sql = """ + INSERT INTO staging_location_points ( + partition_key, + timestamp, + user_id, + device_id, + geom, + elevation_meters, + accuracy_meters + ) VALUES (?, ?, ?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?, ?) + """; + + List filtered = batch.stream().filter(LocationPoint::isValid).toList(); + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + LocationPoint point = filtered.get(i); + ps.setObject(1, partitionKey); + ps.setTimestamp(2, Timestamp.from(point.getTimestamp())); + ps.setLong(3, user.getId()); + ps.setObject(4, device.id()); + ps.setDouble(5, point.getLongitude()); + ps.setDouble(6, point.getLatitude()); + + if (point.getElevationMeters() != null) { + ps.setDouble(7, point.getElevationMeters()); + } else { + ps.setNull(7, Types.DOUBLE); + } + ps.setDouble(8, point.getAccuracyMeters()); + } + + @Override + public int getBatchSize() { + return filtered.size(); + } + }); + } + + @Transactional + public int promote(String partitionKey) { + String sql = """ + INSERT INTO raw_source_points ( + user_id, device_id, timestamp, accuracy_meters, elevation_meters, + geom, invalid, status + ) + SELECT + user_id, device_id, timestamp, accuracy_meters, elevation_meters, + geom, false, 0 + FROM staging_location_points + WHERE partition_key = ? AND promoted = FALSE + ON CONFLICT (user_id, device_id, timestamp) DO NOTHING; + """; + int update = jdbcTemplate.update(sql, partitionKey); + this.jdbcTemplate.update("UPDATE staging_location_points SET promoted = TRUE WHERE partition_key = ? AND promoted = FALSE", partitionKey); + return update; + } + + public TimeRange getTimeRange(String partitionKey) { + String sql = "SELECT MIN(timestamp) as start_time, MAX(timestamp) as end_time FROM staging_location_points WHERE partition_key = ? AND promoted = FALSE"; + return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { + Timestamp start = rs.getTimestamp("start_time"); + Timestamp end = rs.getTimestamp("end_time"); + + if (start == null || end == null) { + return null; + } + + return new TimeRange(start.toInstant(), end.toInstant()); + }, partitionKey); + } + + @Scheduled(cron = "${reitti.import.staging.cleanup.cron}") + public void nightlyCleanup() { + List partitions = jdbcTemplate.queryForList( + "SELECT relid::regclass::text FROM pg_partition_tree('staging_location_points') WHERE isleaf = true", + String.class + ); + + for (String part : partitions) { + try { + String tableNameOnly = part.contains(".") ? part.substring(part.lastIndexOf('.') + 1) : part; + + Boolean isInactive = jdbcTemplate.queryForObject(""" + SELECT (now() - GREATEST(last_vacuum, last_analyze, last_autoanalyze, now() - interval '1 year')) > interval '12 hours' + FROM pg_stat_user_tables WHERE relname = ? + """, Boolean.class, tableNameOnly); + + // 3. Check Promotion Status: Any data left behind? + Integer unpromotedCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM " + part + " WHERE promoted = FALSE", Integer.class); + + // 4. Execution: Only drop if it's quiet AND finished + if (Boolean.TRUE.equals(isInactive) && unpromotedCount != null && unpromotedCount == 0) { + log.info("Janitor: Dropping fully promoted and inactive partition [{}]", part); + jdbcTemplate.execute("DROP TABLE " + part); + } else { + log.debug("Janitor: Skipping partition [{}]. Inactive: {}, Unpromoted: {}", + part, isInactive, unpromotedCount); + } + } catch (Exception e) { + log.error("Janitor: Failed to process cleanup for partition [{}]", part, e); + } + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/PatchDeviceOntoTimelineJob.java b/src/main/java/com/dedicatedcode/reitti/service/processing/PatchDeviceOntoTimelineJob.java new file mode 100644 index 000000000..6d730adea --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/PatchDeviceOntoTimelineJob.java @@ -0,0 +1,76 @@ +package com.dedicatedcode.reitti.service.processing; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.I18nService; +import com.dedicatedcode.reitti.service.JobContext; +import com.dedicatedcode.reitti.service.TimelineService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.workbench.TimelineOverrideService; +import com.github.kagkarlsson.scheduler.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.UUID; + +@Service +public class PatchDeviceOntoTimelineJob { + private static final Logger log = LoggerFactory.getLogger(PatchDeviceOntoTimelineJob.class); + + private final TimelineOverrideService timelineOverrideService; + private final JobSchedulingService jobSchedulingService; + private final Task updateCuratedTimelineTask; + private final I18nService i18n; + public PatchDeviceOntoTimelineJob(TimelineOverrideService timelineOverrideService, + JobSchedulingService jobSchedulingService, + Task updateCuratedTimelineTask, + I18nService i18n) { + this.timelineOverrideService = timelineOverrideService; + this.jobSchedulingService = jobSchedulingService; + this.updateCuratedTimelineTask = updateCuratedTimelineTask; + this.i18n = i18n; + } + + public void execute(TaskData taskData) { + log.debug("Updating timeline override for user [{}], device [{}] between [{}] and [{}]", taskData.user, taskData.device, taskData.start, taskData.end); + this.timelineOverrideService.setTimelineOverride(taskData.user, taskData.device, taskData.start, taskData.end); + log.info("Updated timeline override for user [{}], device [{}] between [{}] and [{}]", taskData.user, taskData.device, taskData.start, taskData.end); + this.jobSchedulingService.enqueueTask(updateCuratedTimelineTask, + new UpdateCuratedTimelineJob.TaskData(taskData.user, taskData.device, TimeRange.of(taskData.start, taskData.end)) + .withParentJobId(taskData.getJobId()), JobSchedulingService.Metadata.builder() + .user(taskData.user) + .friendlyName(i18n.translate("jobs.recalculate_timeline.stitching.friendly_name", taskData.start, taskData.end)) + .jobType(JobType.TIMELINE_STITCHING).build()); + } + + public static final class TaskData extends JobContext { + private final User user; + private final Device device; + private final Instant start; + private final Instant end; + + public TaskData(User user, Device device, Instant start, Instant end) { + this(user, device, start, end, null, null); + } + public TaskData(User user, Device device, Instant start, Instant end, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + this.device = device; + this.start = start; + this.end = end; + } + + @Override + public TaskData withJobId(UUID jobId) { + return new TaskData(user, device, start, end, jobId, parentJobId); + } + + @Override + public TaskData withParentJobId(UUID parentJobId) { + return new TaskData(user, device, start, end, jobId, parentJobId); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTrigger.java b/src/main/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTrigger.java index 4c4b8b518..100bf8034 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTrigger.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTrigger.java @@ -4,6 +4,7 @@ import com.dedicatedcode.reitti.event.TriggerProcessingEvent; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.JobMetadataRepository; import com.dedicatedcode.reitti.repository.PreviewRawLocationPointJdbcService; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; import com.dedicatedcode.reitti.repository.UserJdbcService; @@ -11,15 +12,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadPoolExecutor; @Service public class ProcessingPipelineTrigger { @@ -29,51 +27,39 @@ public class ProcessingPipelineTrigger { private final RawLocationPointJdbcService rawLocationPointJdbcService; private final PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService; private final UserJdbcService userJdbcService; - private final UnifiedLocationProcessingService unifiedLocationProcessingService; + private final UnifiedLocationProcessingService locationProcessTask; + private final JobMetadataRepository jobMetadataRepository; private final int batchSize; - private final ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(1); public ProcessingPipelineTrigger(ImportStateHolder stateHolder, RawLocationPointJdbcService rawLocationPointJdbcService, PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService, UserJdbcService userJdbcService, - UnifiedLocationProcessingService unifiedLocationProcessingService, - @Value("${reitti.import.batch-size:100}") int batchSize) { + JobMetadataRepository jobMetadataRepository, + @Value("${reitti.import.batch-size:100}") int batchSize, + UnifiedLocationProcessingService locationProcessTask) { this.stateHolder = stateHolder; this.rawLocationPointJdbcService = rawLocationPointJdbcService; this.previewRawLocationPointJdbcService = previewRawLocationPointJdbcService; this.userJdbcService = userJdbcService; - this.unifiedLocationProcessingService = unifiedLocationProcessingService; + this.jobMetadataRepository = jobMetadataRepository; this.batchSize = batchSize; + this.locationProcessTask = locationProcessTask; } - @Scheduled(cron = "${reitti.process-data.schedule}") - public void start() { - if (stateHolder.isImportRunning() || !isIdle()) { - log.warn("Data Import is currently running, wil skip this run"); - return; - } - for (User user : userJdbcService.findAll()) { - handleDataForUser(user, null, UUID.randomUUID().toString(), false); - } - } - - public void start(User user) { - handleDataForUser(user, null, UUID.randomUUID().toString(), false); - } - - public void handle(TriggerProcessingEvent event, boolean immediate) { + public void execute(TriggerProcessingEvent event) { Optional byUsername = this.userJdbcService.findByUsername(event.getUsername()); if (byUsername.isPresent()) { - handleDataForUser(byUsername.get(), event.getPreviewId(), event.getTraceId(), immediate); + handleDataForUser(event.getJobId(), byUsername.get(), event.getPreviewId(), event.getTraceId(), event.getParentJobId()); } else { log.warn("No user found for username: {}", event.getUsername()); } } - private void handleDataForUser(User user, String previewId, String traceId, boolean immediate) { + private void handleDataForUser(UUID jobId, User user, String previewId, String traceId, UUID parentJobId) { int totalProcessed = 0; + long maxPoints = this.rawLocationPointJdbcService.countUnprocessedByUser(user); while (true) { stateHolder.importStarted(); try { @@ -85,6 +71,7 @@ private void handleDataForUser(User user, String previewId, String traceId, bool } if (currentBatch.isEmpty()) { + jobMetadataRepository.updateProgress(jobId, totalProcessed, maxPoints, "Done"); break; } @@ -97,26 +84,15 @@ private void handleDataForUser(User user, String previewId, String traceId, bool previewRawLocationPointJdbcService.bulkUpdateProcessedStatus(currentBatch); } - if (!immediate) { - executorService.submit(() -> unifiedLocationProcessingService.processLocationEvent(new LocationProcessEvent(user.getUsername(), earliest, latest, previewId, traceId))); - } else { - unifiedLocationProcessingService.processLocationEvent(new LocationProcessEvent(user.getUsername(), earliest, latest, previewId, traceId)); - } + LocationProcessEvent data = new LocationProcessEvent(user.getUsername(), earliest, latest, previewId, traceId, parentJobId); + locationProcessTask.processLocationEvent(data); totalProcessed += currentBatch.size(); + jobMetadataRepository.updateProgress(jobId,totalProcessed, maxPoints, "Processing..."); } catch (Exception e) { log.error("Error processing batch for user [{}]", user.getId(), e); } } - stateHolder.importFinished(); log.debug("Processed [{}] unprocessed points for user [{}]", totalProcessed, user.getId()); } - - public boolean isIdle() { - return executorService.getQueue().isEmpty() && - executorService.getActiveCount() == 0; - } - public int getPendingCount() { - return executorService.getActiveCount() + executorService.getQueue().size(); - } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticLocationPointGenerator.java b/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticLocationPointGenerator.java index 0dfc2fe39..720bfdf2e 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticLocationPointGenerator.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticLocationPointGenerator.java @@ -52,9 +52,10 @@ public List generateSyntheticPoints( double speed = distance / totalDuration; // meters per second // Apply speed-based transformation: slower speeds shift points towards start - // Using a power function where speed < 1 m/s creates stronger clustering at start - double speedFactor = Math.min(speed / 5.0, 1.0); // normalize to 5 m/s as reference - double ratio = Math.pow(timeRatio, 1.0 / (speedFactor + 0.5)); + // Using a more aggressive power function to cluster points at the beginning + double speedFactor = Math.min(speed / 2.0, 1.0); // normalize to 2 m/s as reference (more sensitive) + double exponent = 1.0 / (speedFactor * 0.8 + 0.1); // More aggressive exponent (higher values = more clustering at start) + double ratio = Math.pow(timeRatio, exponent); // Interpolate coordinates GeoPoint interpolatedCoords = interpolateCoordinates( diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticPointInserter.java b/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticPointInserter.java new file mode 100644 index 000000000..260e78150 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/SyntheticPointInserter.java @@ -0,0 +1,129 @@ +package com.dedicatedcode.reitti.service.processing; + +import com.dedicatedcode.reitti.config.LocationDensityConfig; +import com.dedicatedcode.reitti.dto.LocationPoint; +import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.processing.DetectionParameter; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.service.VisitDetectionParametersService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Service +public class SyntheticPointInserter { + + private static final Logger logger = LoggerFactory.getLogger(SyntheticPointInserter.class); + + private final LocationDensityConfig config; + private final RawLocationPointJdbcService rawLocationPointService; + private final SyntheticLocationPointGenerator syntheticGenerator; + private final VisitDetectionParametersService visitDetectionParametersService; + private final int maxBatchSize; + + public SyntheticPointInserter(LocationDensityConfig config, + RawLocationPointJdbcService rawLocationPointService, + SyntheticLocationPointGenerator syntheticGenerator, + VisitDetectionParametersService visitDetectionParametersService, + @Value("${reitti.import.batch-size:10000}") int maxBatchSize) { + this.config = config; + this.rawLocationPointService = rawLocationPointService; + this.syntheticGenerator = syntheticGenerator; + this.visitDetectionParametersService = visitDetectionParametersService; + this.maxBatchSize = maxBatchSize; + } + + /** + * Processes the given time range: deletes old synthetic points, then + * inserts new synthetic points where real-point gaps are too large. + * + * @param user the owning user + * @param inputRange the time range that covers the newly arrived points + */ + public void fillGaps(User user, TimeRange inputRange) { + // 1. Fetch density configuration (using the earliest point time) + DetectionParameter detectionParams = visitDetectionParametersService.getCurrentConfiguration( + user, inputRange.start()); + DetectionParameter.LocationDensity densityConfig = detectionParams.getLocationDensity(); + + // 2. Expand range to catch boundary gaps + long maxInterpolationGapMinutes = densityConfig.getMaxInterpolationGapMinutes(); + Duration window = Duration.ofMinutes(maxInterpolationGapMinutes); + TimeRange expandedRange = new TimeRange( + inputRange.start().minus(window), + inputRange.end().plus(window) + ); + + // 3. Delete all existing synthetic points in the expanded range + rawLocationPointService.deleteSyntheticPointsInRange(user, expandedRange.start(), expandedRange.end()); + + Instant currentStart = expandedRange.start(); + while (currentStart.isBefore(expandedRange.end())) { + // 4. Fetch all real points in the expanded range + List realPoints = rawLocationPointService + .findByUserAndTimestampBetweenOrderByTimestampAsc( + user, + currentStart, + expandedRange.end(), + false, + 0, + maxBatchSize + ); + + // 5. Sort deterministically (same logic as original) + realPoints.sort(Comparator + .comparing(RawLocationPoint::getTimestamp) + .thenComparing(p -> p.getGeom().latitude()) + .thenComparing(p -> p.getGeom().longitude()) + .thenComparing(RawLocationPoint::isSynthetic)); + + // 6. Process gaps + processGaps(user, realPoints, densityConfig); + if (realPoints.isEmpty()) break; + currentStart = realPoints.getLast().getTimestamp().plus(1, ChronoUnit.MILLIS); + } + + } + + private void processGaps(User user, + List sortedRealPoints, + DetectionParameter.LocationDensity densityConfig) { + if (sortedRealPoints.size() < 2) return; + + int gapThresholdSeconds = config.getGapThresholdSeconds(); + long maxInterpolationSeconds = densityConfig.getMaxInterpolationGapMinutes() * 60L; + + List allSyntheticPoints = new ArrayList<>(); + + for (int i = 0; i < sortedRealPoints.size() - 1; i++) { + RawLocationPoint current = sortedRealPoints.get(i); + RawLocationPoint next = sortedRealPoints.get(i + 1); + + long gapSeconds = Duration.between(current.getTimestamp(), next.getTimestamp()).getSeconds(); + if (gapSeconds > gapThresholdSeconds && gapSeconds <= maxInterpolationSeconds) { + List syntheticPoints = syntheticGenerator.generateSyntheticPoints( + current, next, + config.getTargetPointsPerMinute(), + densityConfig.getMaxInterpolationDistanceMeters() + ); + logger.trace("Gap of {}s between {} and {} -> {} synthetic points", + gapSeconds, current.getTimestamp(), next.getTimestamp(), syntheticPoints.size()); + allSyntheticPoints.addAll(syntheticPoints); + } + } + + if (!allSyntheticPoints.isEmpty()) { + int inserted = rawLocationPointService.bulkInsertSynthetic(user, allSyntheticPoints); + logger.debug("Inserted {} synthetic points for user {} between {} and {}", inserted, user.getUsername(), sortedRealPoints.getFirst().getTimestamp(), sortedRealPoints.getLast().getTimestamp()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/TimeRange.java b/src/main/java/com/dedicatedcode/reitti/service/processing/TimeRange.java new file mode 100644 index 000000000..579714a7e --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/TimeRange.java @@ -0,0 +1,25 @@ +package com.dedicatedcode.reitti.service.processing; + +import java.io.Serializable; +import java.time.Instant; + +public record TimeRange(Instant start, Instant end) implements Serializable { + public static TimeRange empty() { + return new TimeRange(null, null); + } + + public static TimeRange of(Instant start, Instant end) { + return new TimeRange(start, end); + } + + public TimeRange extend(TimeRange other) { + if (this.equals(empty())) { + return other; + } else if (other.equals(empty())) { + return this; + } + Instant start = this.start == null ? other.start : this.start.isBefore(other.start) ? this.start : other.start; + Instant end = this.end == null ? other.end : this.end.isAfter(other.end) ? this.end : other.end; + return new TimeRange(start, end); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java index af50a944d..f0469f8f6 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java @@ -4,13 +4,16 @@ import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent; import com.dedicatedcode.reitti.model.PlaceInformationOverride; import com.dedicatedcode.reitti.model.geo.*; +import com.dedicatedcode.reitti.model.metadata.MemoryMetadata; import com.dedicatedcode.reitti.model.processing.DetectionParameter; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.*; import com.dedicatedcode.reitti.service.GeoLocationTimezoneService; +import com.dedicatedcode.reitti.service.MetadataOverrideService; import com.dedicatedcode.reitti.service.UserNotificationService; import com.dedicatedcode.reitti.service.VisitDetectionParametersService; -import com.dedicatedcode.reitti.service.queue.RedisQueueService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.github.kagkarlsson.scheduler.task.Task; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; @@ -23,9 +26,8 @@ import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.*; -import java.util.stream.Collectors; -import static com.dedicatedcode.reitti.service.MessageDispatcherService.PLACE_CREATED_QUEUE; +import static com.dedicatedcode.reitti.service.jobs.JobType.REVERSE_GEOCODE; /** * Unified service that processes the entire GPS pipeline atomically per user. @@ -53,7 +55,9 @@ public class UnifiedLocationProcessingService { private final UserNotificationService userNotificationService; private final GeoLocationTimezoneService timezoneService; private final GeometryFactory geometryFactory; - private final RedisQueueService messageEnqueuer; + private final MetadataOverrideService metadataOverrideService; + private final JobSchedulingService jobScheduler; + private final Task reverseGeocodingTask; public UnifiedLocationProcessingService( UserJdbcService userJdbcService, @@ -71,9 +75,9 @@ public UnifiedLocationProcessingService( TransportModeService transportModeService, UserNotificationService userNotificationService, GeoLocationTimezoneService timezoneService, - GeometryFactory geometryFactory, - RedisQueueService messageEnqueuer) { - + GeometryFactory geometryFactory, MetadataOverrideService metadataOverrideService, + JobSchedulingService jobScheduler, + Task reverseGeocodingTask) { this.userJdbcService = userJdbcService; this.rawLocationPointJdbcService = rawLocationPointJdbcService; this.previewRawLocationPointJdbcService = previewRawLocationPointJdbcService; @@ -90,7 +94,9 @@ public UnifiedLocationProcessingService( this.userNotificationService = userNotificationService; this.timezoneService = timezoneService; this.geometryFactory = geometryFactory; - this.messageEnqueuer = messageEnqueuer; + this.metadataOverrideService = metadataOverrideService; + this.jobScheduler = jobScheduler; + this.reverseGeocodingTask = reverseGeocodingTask; } /** @@ -226,13 +232,11 @@ private VisitDetectionResult detectVisits(User user, LocationProcessEvent event) Instant windowEnd = event.getLatest().plus(1, ChronoUnit.DAYS); DetectionParameter currentConfiguration; - DetectionParameter.VisitDetection detectionParams; if (previewId == null) { currentConfiguration = visitDetectionParametersService.getCurrentConfiguration(user, windowStart); } else { currentConfiguration = previewVisitDetectionParametersJdbcService.findCurrent(user, previewId); } - detectionParams = currentConfiguration.getVisitDetection(); List existingProcessedVisits; if (previewId == null) { @@ -255,16 +259,12 @@ private VisitDetectionResult detectVisits(User user, LocationProcessEvent event) List timeOrderedPoints; if (previewId == null) { timeOrderedPoints = rawLocationPointJdbcService - .findByUserAndTimestampBetweenOrderByTimestampAsc(user, windowStart, windowEnd, true, false, false); + .findByUserAndTimestampBetweenOrderByTimestampAsc(user, windowStart, windowEnd, true); } else { timeOrderedPoints = previewRawLocationPointJdbcService .findByUserAndTimestampBetweenOrderByTimestampAsc(user, previewId, windowStart, windowEnd); } - timeOrderedPoints = timeOrderedPoints.stream() - .filter(p -> !p.isIgnored() && !p.isInvalid()) - .toList(); - logger.debug("Loaded {} valid points in [{}, {}]", timeOrderedPoints.size(), windowStart, windowEnd); List stayPoints = detectStayPointsSlidingWindow(timeOrderedPoints, currentConfiguration); @@ -298,7 +298,7 @@ private VisitMergingResult mergeVisits(User user, String previewId, String trace .getVisitMerging(); } - // Expand search window for merging + // Expand the search window for merging Instant searchStart = initialStart; Instant searchEnd = initialEnd; @@ -314,7 +314,7 @@ private VisitMergingResult mergeVisits(User user, String previewId, String trace previewProcessedVisitJdbcService.deleteAll(existingProcessedVisits); } - // Expand window based on deleted processed visits + // Expand the window based on deleted processed visits if (!existingProcessedVisits.isEmpty()) { if (existingProcessedVisits.getFirst().getStartTime().isBefore(searchStart)) { searchStart = existingProcessedVisits.getFirst().getStartTime(); @@ -325,7 +325,7 @@ private VisitMergingResult mergeVisits(User user, String previewId, String trace } if (allVisits.isEmpty()) { - return new VisitMergingResult(List.of(), List.of(), searchStart, searchEnd, System.currentTimeMillis() - start); + return new VisitMergingResult(new ArrayList<>(), new ArrayList<>(), searchStart, searchEnd, System.currentTimeMillis() - start); } // Merge visits chronologically @@ -482,53 +482,6 @@ private List detectStayPointsSlidingWindow( return stayPoints; } - private List detectStayPointsFromTrajectory( - Map> points, - DetectionParameter.VisitDetection visitDetectionParameters) { - logger.debug("Starting cluster-based stay point detection with {} different spatial clusters.", points.size()); - - List> clusters = new ArrayList<>(); - - //split them up when time is x seconds between - for (List clusteredByLocation : points.values()) { - logger.debug("Start splitting up geospatial cluster with [{}] elements based on minimum time [{}]s between points", clusteredByLocation.size(), visitDetectionParameters.getMaxMergeTimeBetweenSameStayPoints()); - //first sort them by timestamp - clusteredByLocation.sort(Comparator.comparing(RawLocationPoint::getTimestamp)); - - List currentTimedCluster = new ArrayList<>(); - clusters.add(currentTimedCluster); - currentTimedCluster.add(clusteredByLocation.getFirst()); - - Instant currentTime = clusteredByLocation.getFirst().getTimestamp(); - - for (int i = 1; i < clusteredByLocation.size(); i++) { - RawLocationPoint next = clusteredByLocation.get(i); - if (Duration.between(currentTime, next.getTimestamp()).getSeconds() < visitDetectionParameters.getMaxMergeTimeBetweenSameStayPoints()) { - currentTimedCluster.add(next); - } else { - currentTimedCluster = new ArrayList<>(); - currentTimedCluster.add(next); - clusters.add(currentTimedCluster); - } - currentTime = next.getTimestamp(); - } - } - - logger.debug("Detected {} stay points after splitting them up.", clusters.size()); - //filter them by duration - List> filteredByMinimumDuration = clusters.stream() - .filter(c -> Duration.between(c.getFirst().getTimestamp(), c.getLast().getTimestamp()).toSeconds() > visitDetectionParameters.getMinimumStayTimeInSeconds()) - .toList(); - - logger.debug("Found {} valid clusters after duration filtering with minimum stay time [{}]s", filteredByMinimumDuration.size(), visitDetectionParameters.getMinimumStayTimeInSeconds()); - - // Step 3: Convert valid clusters to stay points - return filteredByMinimumDuration.stream() - .map(this::createStayPoint) - .sorted(Comparator.comparing(StayPoint::getArrivalTime)) - .collect(Collectors.toList()); - } - private List mergeVisitsChronologically( User user, String previewId, String traceId, List visits, DetectionParameter.VisitMerging mergeConfiguration) { @@ -584,7 +537,7 @@ private List mergeVisitsChronologically( currentEndTime = nextVisit.getEndTime().isAfter(currentEndTime) ? nextVisit.getEndTime() : currentEndTime; } else { - ProcessedVisit processedVisit = createProcessedVisit(currentPlace, currentStartTime, currentEndTime); + ProcessedVisit processedVisit = createProcessedVisit(user, currentPlace, currentStartTime, currentEndTime); if (processedVisit != null) { result.add(processedVisit); } @@ -594,14 +547,14 @@ private List mergeVisitsChronologically( } } - ProcessedVisit lastProcessedVisit = createProcessedVisit(currentPlace, currentStartTime, currentEndTime); + ProcessedVisit lastProcessedVisit = createProcessedVisit(user, currentPlace, currentStartTime, currentEndTime); if (lastProcessedVisit != null) { result.add(lastProcessedVisit); } return result; } - private ProcessedVisit createProcessedVisit(SignificantPlace place, Instant startTime, Instant endTime) { + private ProcessedVisit createProcessedVisit(User user, SignificantPlace place, Instant startTime, Instant endTime) { if (endTime.isBefore(startTime)) { logger.warn("Skipping zero or negative duration processed visit for place [{}] between [{}] and [{}]", place.getId(), startTime, endTime); return null; // Indicate to skip @@ -611,7 +564,9 @@ private ProcessedVisit createProcessedVisit(SignificantPlace place, Instant star return null; } logger.debug("Creating processed visit for place [{}] between [{}] and [{}]", place.getId(), startTime, endTime); - return new ProcessedVisit(place, startTime, endTime, endTime.getEpochSecond() - startTime.getEpochSecond()); + + Map metadata = this.metadataOverrideService.findOverlappingMetadata(user, startTime, endTime).map(MemoryMetadata::getProperties).orElse(null); + return new ProcessedVisit(place, startTime, endTime, endTime.getEpochSecond() - startTime.getEpochSecond(), metadata); } private StayPoint createStayPoint(List clusterPoints) { @@ -825,6 +780,8 @@ private Trip createTripBetweenVisits(User user, String previewId, double travelledDistanceMeters = GeoUtils.calculateTripDistance(tripPoints); // Create a new trip TransportMode transportMode = this.transportModeService.inferTransportMode(user, tripPoints, tripStartTime, tripEndTime); + Map metadata = this.metadataOverrideService.findOverlappingMetadata(user, tripStartTime, tripEndTime).map(MemoryMetadata::getProperties).orElse(null); + Trip trip = new Trip( tripStartTime, tripEndTime, @@ -833,7 +790,8 @@ private Trip createTripBetweenVisits(User user, String previewId, travelledDistanceMeters, transportMode, startVisit, - endVisit + endVisit, + metadata ); logger.debug("Created trip from {} to {}: travelled distance={}m, mode={}", Optional.ofNullable(startVisit.getPlace().getName()).orElse("Unknown Name"), @@ -913,7 +871,12 @@ private void publishSignificantPlaceCreatedEvent(User user, SignificantPlace pla place.getLongitudeCentroid(), traceId ); - messageEnqueuer.enqueue(PLACE_CREATED_QUEUE, event); + this.jobScheduler.enqueueTask(reverseGeocodingTask, event, + JobSchedulingService.Metadata.builder() + .user(user) + .jobType(REVERSE_GEOCODE) + .friendlyName(String.format("Reverse geocoding for %6f,%6f", place.getLatitudeCentroid(), place.getLongitudeCentroid())) + .build()); logger.info("Published SignificantPlaceCreatedEvent for place ID: {}", place.getId()); } diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/UpdateCuratedTimelineJob.java b/src/main/java/com/dedicatedcode/reitti/service/processing/UpdateCuratedTimelineJob.java new file mode 100644 index 000000000..32c76c51c --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/UpdateCuratedTimelineJob.java @@ -0,0 +1,82 @@ +package com.dedicatedcode.reitti.service.processing; + +import com.dedicatedcode.reitti.event.TriggerProcessingEvent; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.service.JobContext; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.github.kagkarlsson.scheduler.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +import static com.dedicatedcode.reitti.service.jobs.JobType.VISIT_TRIP_DETECTION; + +@Service +public class UpdateCuratedTimelineJob { + private static final Logger log = LoggerFactory.getLogger(UpdateCuratedTimelineJob.class); + + private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final SyntheticPointInserter syntheticPointInserter; + private final JobSchedulingService jobSchedulingService; + private final Task processingEventTask; + + public UpdateCuratedTimelineJob(RawLocationPointJdbcService rawLocationPointJdbcService, + SyntheticPointInserter syntheticPointInserter, + JobSchedulingService jobSchedulingService, + Task processingEventTask) { + this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.syntheticPointInserter = syntheticPointInserter; + this.jobSchedulingService = jobSchedulingService; + this.processingEventTask = processingEventTask; + } + + public void execute(TaskData data) { + log.debug("Starting updating main timeline for user [{}] and device[{}] in timeRange [{}]", data.user, data.device, data.timeRange); + //1. clear main timeline + this.rawLocationPointJdbcService.dropForReSeeding(data.user, data.timeRange); + //2. update main timeline from view + int updatedCount = this.rawLocationPointJdbcService.updateFromDevices(data.user, data.timeRange); + log.debug("Updated {} timeline points for user [{}] and device[{}] in timeRange [{}]", updatedCount, data.user, data.device, data.timeRange); + //3. insert new possible synthetic points + this.syntheticPointInserter.fillGaps(data.user, data.timeRange); + //4. trigger new processing job + this.jobSchedulingService.enqueueTask(processingEventTask, + new TriggerProcessingEvent(data.user.getUsername(), null, null), + JobSchedulingService.Metadata.builder().jobType(VISIT_TRIP_DETECTION) + .user(data.user) + .friendlyName("Detect Visits and Trips").build()); + + } + + public static class TaskData extends JobContext { + + private final User user; + private final Device device; + private final TimeRange timeRange; + + public TaskData(User user, Device device, TimeRange timeRange) { + this(user, device, timeRange, null, null); + } + + public TaskData(User user, Device device, TimeRange timeRange, UUID jobId, UUID parentJobId) { + super(jobId, parentJobId); + this.user = user; + this.device = device; + this.timeRange = timeRange; + } + + @Override + public TaskData withJobId(UUID jobId) { + return new TaskData(user, device, timeRange, jobId, parentJobId); + } + + @Override + public TaskData withParentJobId(UUID parentJobId) { + return new TaskData(user, device, timeRange, jobId, parentJobId); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/MessageHandler.java b/src/main/java/com/dedicatedcode/reitti/service/queue/MessageHandler.java deleted file mode 100644 index 1dd268044..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/MessageHandler.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -@FunctionalInterface -public interface MessageHandler { - void handle(T payload) throws Exception; -} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/QueueMessage.java b/src/main/java/com/dedicatedcode/reitti/service/queue/QueueMessage.java deleted file mode 100644 index b1e22745c..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/QueueMessage.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.io.Serializable; -import java.time.Instant; - -public class QueueMessage implements Serializable { - private final String id; - private final T payload; - private final Instant enqueuedAt; - private final String queueName; - private final int retryCount; - private final String originalQueue; - - @JsonCreator - public QueueMessage(@JsonProperty("id") String id, - @JsonProperty("payload") T payload, - @JsonProperty("enqueuedAt") Instant enqueuedAt, - @JsonProperty("queueName") String queueName, - @JsonProperty("retryCount") int retryCount, - @JsonProperty("originalQueue") String originalQueue) { - this.id = id; - this.payload = payload; - this.enqueuedAt = enqueuedAt; - this.queueName = queueName; - this.retryCount = retryCount; - this.originalQueue = originalQueue; - } - - public String getId() { - return id; - } - - public T getPayload() { - return payload; - } - - public Instant getEnqueuedAt() { - return enqueuedAt; - } - - public String getQueueName() { - return queueName; - } - - public int getRetryCount() { - return retryCount; - } - - public String getOriginalQueue() { - return originalQueue; - } - - - public QueueMessage withRetryCount(int newRetryCount) { - return new QueueMessage<>( - this.id, - this.payload, - this.enqueuedAt, - this.queueName, - newRetryCount, - this.originalQueue - ); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/QueueStatistics.java b/src/main/java/com/dedicatedcode/reitti/service/queue/QueueStatistics.java deleted file mode 100644 index a4460d49d..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/QueueStatistics.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -import java.time.Instant; -import java.util.Map; - -public record QueueStatistics( - String queueName, - long totalEnqueued, - long totalProcessed, - long totalFailed, - long totalRetried, - long pendingCount, - double processingRate, - long currentQueueLength, - long currentProcessingLength, - Instant createdAt, - Instant lastEnqueuedAt, - Instant lastProcessedAt, - Instant lastFailedAt, - Instant lastActivityAt, - String status, - int concurrency -) { - - public static QueueStatistics empty(String queueName) { - return new QueueStatistics( - queueName, - 0L, 0L, 0L, 0L, 0L, 0.0, - 0L, 0L, - null, null, null, null, null, - "INACTIVE", 1 - ); - } - - public static QueueStatistics fromMap(String queueName, Map rawStats) { - return new QueueStatistics( - queueName, - getLong(rawStats, "totalEnqueued", 0L), - getLong(rawStats, "totalProcessed", 0L), - getLong(rawStats, "totalFailed", 0L), - getLong(rawStats, "totalRetried", 0L), - getLong(rawStats, "pendingCount", 0L), - getDouble(rawStats, "processingRate", 0.0), - getLong(rawStats, "currentQueueLength", 0L), - getLong(rawStats, "currentProcessingLength", 0L), - getInstant(rawStats, "createdAt"), - getInstant(rawStats, "lastEnqueuedAt"), - getInstant(rawStats, "lastProcessedAt"), - getInstant(rawStats, "lastFailedAt"), - getInstant(rawStats, "lastActivityAt"), - getString(rawStats, "status", "UNKNOWN"), - getInt(rawStats, "concurrency", 1) - ); - } - - private static Long getLong(Map map, String key, Long defaultValue) { - Object value = map.get(key); - if (value instanceof Long) return (Long) value; - if (value instanceof Integer) return ((Integer) value).longValue(); - if (value instanceof String) { - try { - return Long.parseLong((String) value); - } catch (NumberFormatException e) { - return defaultValue; - } - } - return defaultValue; - } - - private static Double getDouble(Map map, String key, Double defaultValue) { - Object value = map.get(key); - if (value instanceof Double) return (Double) value; - if (value instanceof Float) return ((Float) value).doubleValue(); - if (value instanceof String) { - try { - return Double.parseDouble((String) value); - } catch (NumberFormatException e) { - return defaultValue; - } - } - return defaultValue; - } - - private static Integer getInt(Map map, String key, Integer defaultValue) { - Object value = map.get(key); - if (value instanceof Integer) return (Integer) value; - if (value instanceof Long) return ((Long) value).intValue(); - if (value instanceof String) { - try { - return Integer.parseInt((String) value); - } catch (NumberFormatException e) { - return defaultValue; - } - } - return defaultValue; - } - - private static String getString(Map map, String key, String defaultValue) { - Object value = map.get(key); - if (value instanceof String) return (String) value; - return defaultValue; - } - - private static Instant getInstant(Map map, String key) { - Object value = map.get(key); - if (value instanceof Instant) return (Instant) value; - if (value instanceof String) { - try { - return Instant.parse((String) value); - } catch (Exception e) { - return null; - } - } - return null; - } -} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueAnnotationProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueAnnotationProcessor.java deleted file mode 100644 index 945126138..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueAnnotationProcessor.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationContext; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -@Component -public class RedisQueueAnnotationProcessor { - private static final Logger log = LoggerFactory.getLogger(RedisQueueAnnotationProcessor.class); - - private final ApplicationContext applicationContext; - - private final RedisQueueService redisQueueService; - - public RedisQueueAnnotationProcessor(ApplicationContext applicationContext, RedisQueueService redisQueueService) { - this.applicationContext = applicationContext; - this.redisQueueService = redisQueueService; - } - - @EventListener(ContextRefreshedEvent.class) - public void onApplicationEvent(ContextRefreshedEvent event) { - scanAndRegisterListeners(); - } - - private void scanAndRegisterListeners() { - String[] beanNames = applicationContext.getBeanDefinitionNames(); - - for (String beanName : beanNames) { - Object bean = applicationContext.getBean(beanName); - Method[] methods = bean.getClass().getMethods(); - - for (Method method : methods) { - RedisQueueListener annotation = method.getAnnotation(RedisQueueListener.class); - if (annotation != null) { - registerListener(bean, method, annotation, this.redisQueueService); - } - } - } - } - - private void registerListener(Object bean, Method method, RedisQueueListener annotation, RedisQueueService queueService) { - if (method.getParameterCount() != 1) { - throw new IllegalArgumentException( - "Method " + method.getName() + " must have exactly one parameter"); - } - - Class payloadType = method.getParameterTypes()[0]; - - MessageHandler handler = (MessageHandler) createHandler(bean, method, payloadType); - - queueService.registerHandler( - annotation.value(), - payloadType, - handler, - annotation.concurrency(), - annotation.numRetries(), - annotation.deadLetterQueue() - ); - } - - private MessageHandler createHandler(Object bean, Method method, Class payloadType) { - return (payload) -> { - try { - method.invoke(bean, payload); - } catch (InvocationTargetException e) { - Throwable target = e.getTargetException(); - if (target instanceof Exception) { - throw (Exception) target; - } else if (target instanceof Error) { - throw (Error) target; - } else { - throw new RuntimeException("Unexpected throwable", target); - } - } catch (IllegalAccessException e) { - throw new RuntimeException("Failed to invoke method", e); - } - }; - } -} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueKeys.java b/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueKeys.java deleted file mode 100644 index baf084d83..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueKeys.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -public class RedisQueueKeys { - private final String prefix; - - public RedisQueueKeys(String prefix) { - if (prefix == null || prefix.isEmpty()) { - this.prefix = ""; - } else { - this.prefix = prefix.endsWith(":") ? prefix : prefix + ":"; - } - } - - // Main queue - public String queueKey(String queueName) { - return prefix + "queue:" + queueName; - } - - // Processing queue (for in-flight messages) - public String processingKey(String queueName) { - return prefix + "processing:" + queueName; - } - - // Dead letter queue - public String deadLetterKey(String queueName) { - return prefix + "dlq:" + queueName; - } - - // Retry count hash - public String retryCountKey(String queueName) { - return prefix + "retry:" + queueName; - } - - // Queue metadata - public String metadataKey(String queueName) { - return prefix + "meta:" + queueName; - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueListener.java b/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueListener.java deleted file mode 100644 index e50b0dc32..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueListener.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface RedisQueueListener { - String value(); - int concurrency() default 1; - int numRetries() default 3; - String deadLetterQueue() default ""; -} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueService.java b/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueService.java deleted file mode 100644 index 20704be43..000000000 --- a/src/main/java/com/dedicatedcode/reitti/service/queue/RedisQueueService.java +++ /dev/null @@ -1,514 +0,0 @@ -package com.dedicatedcode.reitti.service.queue; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.dao.DataAccessException; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisOperations; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.SessionCallback; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -@Service -public class RedisQueueService { - private static final Logger log = LoggerFactory.getLogger(RedisQueueService.class); - - private final RedisTemplate redisTemplate; - private final RedisQueueKeys keys; - private final ObjectMapper objectMapper; - private final ExecutorService executorService; - private final Map registeredQueues = new ConcurrentHashMap<>(); - - @Autowired - public RedisQueueService(RedisTemplate redisTemplate, - ObjectMapper objectMapper, - @Value("${spring.cache.redis.key-prefix:}") String keyPrefix) { - this.redisTemplate = redisTemplate; - this.keys = new RedisQueueKeys(keyPrefix); - this.objectMapper = objectMapper; - this.executorService = Executors.newCachedThreadPool(); - } - - @SuppressWarnings("unchecked") - private void updateQueueMetadata(String queueName, long delta, boolean isEnqueue) { - String metaKey = keys.metadataKey(queueName); - - // Use Redis transactions for atomic updates - redisTemplate.execute(new SessionCallback<>() { - @Override - public Object execute(RedisOperations operations) throws DataAccessException { - operations.multi(); - - // Check if metadata exists - Boolean exists = operations.hasKey(metaKey); - if (Boolean.FALSE.equals(exists)) { - Map initialMetadata = new HashMap<>(); - initialMetadata.put("createdAt", Instant.now().toString()); - initialMetadata.put("totalEnqueued", "0"); - initialMetadata.put("totalProcessed", "0"); - initialMetadata.put("totalFailed", "0"); - initialMetadata.put("totalRetried", "0"); - initialMetadata.put("queueName", queueName); - initialMetadata.put("status", "ACTIVE"); - initialMetadata.put("concurrency", "1"); // Default - - operations.opsForHash().putAll(metaKey, initialMetadata); - } - - Instant now = Instant.now(); - - if (isEnqueue) { - // For enqueue operations - use increment which works with Long/String - operations.opsForHash().increment(metaKey, "totalEnqueued", delta); - operations.opsForHash().put(metaKey, "lastEnqueuedAt", now); - - // Update pending count (enqueued - processed) - Long totalEnqueued = (Long) operations.opsForHash().get(metaKey, "totalEnqueued"); - Long totalProcessed = (Long) operations.opsForHash().get(metaKey, "totalProcessed"); - if (totalEnqueued != null && totalProcessed != null) { - operations.opsForHash().put(metaKey, "pendingCount", totalEnqueued - totalProcessed); - } - } else { - // For processing completion/failure - operations.opsForHash().increment(metaKey, "totalProcessed", delta); - operations.opsForHash().put(metaKey, "lastProcessedAt", now); - } - - operations.opsForHash().put(metaKey, "lastActivityAt", now); - - return operations.exec(); - } - }); - } - - private void updateQueueMetadata(String queueName, long delta) { - updateQueueMetadata(queueName, delta, true); - } - - private void updateProcessingSuccess(String queueName) { - updateQueueMetadata(queueName, 1, false); - } - - private void updateFailureStats(String queueName) { - String metaKey = keys.metadataKey(queueName); - redisTemplate.opsForHash().increment(metaKey, "totalFailed", 1); - redisTemplate.opsForHash().put(metaKey, "lastFailedAt", Instant.now().toString()); - } - - private void updateRetryStats(String queueName) { - String metaKey = keys.metadataKey(queueName); - redisTemplate.opsForHash().increment(metaKey, "totalRetried", 1); - } - - public QueueStatistics getQueueStats(String queueName) { - String metaKey = keys.metadataKey(queueName); - - if (!redisTemplate.hasKey(metaKey)) { - return QueueStatistics.empty(queueName); - } - - Map rawHash = redisTemplate.opsForHash().entries(metaKey); - Map stats = new HashMap<>(); - - // Convert raw hash to typed map - rawHash.forEach((key, value) -> { - if (key instanceof String stringKey) { - if (value instanceof String stringValue) { - try { - if (stringValue.matches("\\d+")) { - stats.put(stringKey, Long.parseLong(stringValue)); - } else if (stringValue.matches("\\d+\\.\\d+")) { - stats.put(stringKey, Double.parseDouble(stringValue)); - } else { - stats.put(stringKey, stringValue); - } - } catch (NumberFormatException e) { - stats.put(stringKey, stringValue); - } - } else { - stats.put(stringKey, value); - } - } - }); - - // Calculate derived metrics - long totalEnqueued = ((Integer) stats.get("totalEnqueued")).longValue(); - long totalProcessed = ((Integer) stats.get("totalProcessed")).longValue(); - stats.put("pendingCount", totalEnqueued - totalProcessed); - if (totalEnqueued > 0) { - stats.put("processingRate", (double) totalProcessed / totalEnqueued * 100); - } - - // Get current queue lengths - Long currentQueueLength = redisTemplate.opsForList().size(keys.queueKey(queueName)); - Long currentProcessingLength = redisTemplate.opsForList().size(keys.processingKey(queueName)); - - stats.put("currentQueueLength", currentQueueLength != null ? currentQueueLength : 0L); - stats.put("currentProcessingLength", currentProcessingLength != null ? currentProcessingLength : 0L); - - // Ensure required fields exist - if (!stats.containsKey("pendingCount")) { - stats.put("pendingCount", 0L); - } - if (!stats.containsKey("processingRate")) { - stats.put("processingRate", 0.0); - } - if (!stats.containsKey("status")) { - stats.put("status", "ACTIVE"); - } - if (!stats.containsKey("concurrency")) { - stats.put("concurrency", 1); - } - - return QueueStatistics.fromMap(queueName, stats); - } - - public void enqueue(String queueName, T payload) { - QueueMessage message = new QueueMessage<>( - UUID.randomUUID().toString(), - payload, - Instant.now(), - queueName, - 0, - queueName - ); - - String json = serialize(message); - - // Push to queue - redisTemplate.opsForList().rightPush(keys.queueKey(queueName), json); - - // Update metadata - updateQueueMetadata(queueName, 1); - - log.trace("Enqueued message {} to queue {}", message.getId(), queueName); - } - - public void registerHandler(String queueName, - Class payloadType, - MessageHandler handler, - int concurrency, - int maxRetries, - String deadLetterQueue) { - - QueueMetadata metadata = new QueueMetadata( - queueName, payloadType, handler, maxRetries, - deadLetterQueue != null ? deadLetterQueue : queueName + ".dlq" - ); - - registeredQueues.put(queueName, metadata); - - for (int i = 0; i < concurrency; i++) { - executorService.submit(() -> processMessages(queueName)); - } - } - - private void processMessages(String queueName) { - QueueMetadata metadata = registeredQueues.get(queueName); - if (metadata == null) { - throw new IllegalStateException("Queue not registered: " + queueName); - } - - while (!Thread.currentThread().isInterrupted()) { - try { - Object json = redisTemplate.opsForList() - .rightPopAndLeftPush( - keys.queueKey(queueName), - keys.processingKey(queueName), - 30, TimeUnit.SECONDS); - - if (json != null) { - processMessage(json, metadata); - } - } catch (IllegalStateException | RedisSystemException e) { - if (e.getMessage() != null && ( - e.getMessage().contains("LettuceConnectionFactory has been STOPPED") || - e.getMessage().contains("was destroyed and cannot be used anymore") || - e.getCause().getMessage().contains("Connection closed"))) { - log.debug("Redis connection closed during shutdown, stopping queue {}", queueName); - break; - } - log.error("Error processing messages from queue {}", queueName, e); - } catch (Exception e) { - log.error("Error processing messages from queue {}", queueName, e); - } - } - - log.info("Shutting down processing thread for queue {}", queueName); - } - - @SuppressWarnings("unchecked") - private void processMessage(Object json, QueueMetadata metadata) { - QueueMessage message = (QueueMessage) deserializeMessage(json, metadata.payloadType()); - - try { - MessageHandler handler = (MessageHandler) metadata.handler(); - handler.handle(message.getPayload()); - - redisTemplate.opsForList().remove( - keys.processingKey(message.getQueueName()), - 1, json - ); - - updateProcessingSuccess(message.getQueueName()); - - log.trace("Successfully processed message {}", message.getId()); - - } catch (Exception e) { - updateFailureStats(message.getQueueName()); - handleFailure(message, e, metadata); - } - } - - private void handleFailure(QueueMessage message, - Exception error, - QueueMetadata metadata) { - int currentRetry = message.getRetryCount(); - String json = serialize(message); - - if (currentRetry < metadata.maxRetries()) { - // Retry: move back to main queue with incremented retry count - QueueMessage retryMessage = message.withRetryCount(currentRetry + 1); - String retryJson = serialize(retryMessage); - - redisTemplate.opsForList().rightPush( - keys.queueKey(message.getOriginalQueue()), - retryJson - ); - updateRetryStats(message.getQueueName()); - } else { - redisTemplate.opsForList().rightPush( - keys.deadLetterKey(metadata.deadLetterQueue()), - json - ); - } - - // Remove from processing queue - redisTemplate.opsForList().remove( - keys.processingKey(message.getQueueName()), - 1, json - ); - - log.error("Failed to process message after {} retries: {}", - currentRetry, message.getId(), error); - } - - private String serialize(Object obj) { - try { - return objectMapper.writeValueAsString(obj); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to serialize object", e); - } - } - - private QueueMessage deserializeMessage(Object json, Class payloadType) { - try { - JavaType type = objectMapper.getTypeFactory() - .constructParametricType(QueueMessage.class, payloadType); - return objectMapper.readValue(json.toString(), type); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to deserialize message", e); - } - } - - public Map getAllQueueStats() { - Map allStats = new HashMap<>(); - - for (String queueName : registeredQueues.keySet()) { - allStats.put(queueName, getQueueStats(queueName)); - } - - return allStats; - } - - public QueueSummary getQueueSummary() { - Map allStats = getAllQueueStats(); - - long totalMessages = allStats.values().stream() - .mapToLong(QueueStatistics::currentQueueLength) - .sum(); - - long totalProcessing = allStats.values().stream() - .mapToLong(QueueStatistics::currentProcessingLength) - .sum(); - - long totalPending = allStats.values().stream() - .mapToLong(QueueStatistics::pendingCount) - .sum(); - - return new QueueSummary(totalMessages, totalProcessing, totalPending, allStats.size()); - } - - /** - * Purges all messages from the specified queue (main and processing queues). - * Does not affect dead letter queue or metadata. - * - * @param queueName the name of the queue to purge - */ - public void purgeQueue(String queueName) { - purgeQueue(queueName, false, false); - } - - /** - * Purges all messages from the specified queue with options. - * - * @param queueName the name of the queue to purge - * @param includeDeadLetterQueue if true, also purges the dead letter queue - */ - public void purgeQueue(String queueName, boolean includeDeadLetterQueue) { - purgeQueue(queueName, includeDeadLetterQueue, false); - } - - /** - * Purges all messages from the specified queue with full control. - * - * @param queueName the name of the queue to purge - * @param includeDeadLetterQueue if true, also purges the dead letter queue - * @param resetMetadata if true, resets queue statistics to zero - */ - public void purgeQueue(String queueName, boolean includeDeadLetterQueue, boolean resetMetadata) { - log.debug("Purging queue {} (includeDeadLetterQueue: {}, resetMetadata: {})", - queueName, includeDeadLetterQueue, resetMetadata); - - // Delete main queue - Boolean mainDeleted = redisTemplate.delete(keys.queueKey(queueName)); - - // Delete processing queue - Boolean processingDeleted = redisTemplate.delete(keys.processingKey(queueName)); - - Boolean dlqDeleted = false; - if (includeDeadLetterQueue) { - String dlqName = getDeadLetterQueueName(queueName); - dlqDeleted = redisTemplate.delete(keys.deadLetterKey(dlqName)); - } - - if (resetMetadata) { - resetQueueMetadata(queueName); - } - - log.debug("Purged queue {}: main deleted={}, processing deleted={}, dlq deleted={}", - queueName, mainDeleted, processingDeleted, dlqDeleted); - } - - /** - * Resets all queue statistics to zero while preserving the queue structure. - * - * @param queueName the name of the queue to reset - */ - public void resetQueueStatistics(String queueName) { - resetQueueMetadata(queueName); - log.info("Reset statistics for queue {}", queueName); - } - - /** - * Purges all messages from all registered queues. - * Use with caution - this is a destructive operation. - */ - public void purgeAllQueues() { - log.warn("Purging all registered queues"); - - for (String queueName : registeredQueues.keySet()) { - purgeQueue(queueName, true, false); - } - - log.info("Purged all registered queues"); - } - - /** - * Gets the dead letter queue name for a given queue. - * - * @param queueName the main queue name - * @return the dead letter queue name - */ - private String getDeadLetterQueueName(String queueName) { - QueueMetadata metadata = registeredQueues.get(queueName); - if (metadata != null) { - return metadata.deadLetterQueue(); - } else { - // Default naming convention - return queueName + ".dlq"; - } - } - - /** - * Resets queue metadata statistics to zero. - * - * @param queueName the name of the queue - */ - private void resetQueueMetadata(String queueName) { - String metaKey = keys.metadataKey(queueName); - - if (redisTemplate.hasKey(metaKey)) { - // Reset all counters to zero, preserve other metadata - Map resetValues = new HashMap<>(); - resetValues.put("totalEnqueued", "0"); - resetValues.put("totalProcessed", "0"); - resetValues.put("totalFailed", "0"); - resetValues.put("totalRetried", "0"); - resetValues.put("pendingCount", "0"); - resetValues.put("lastActivityAt", Instant.now().toString()); - - // Update the reset values - redisTemplate.opsForHash().putAll(metaKey, resetValues); - - log.debug("Reset metadata for queue {}", queueName); - } else { - updateQueueMetadata(queueName, 1); - } - } - - /** - * Gets the number of messages currently in the queue (main + processing). - * - * @param queueName the name of the queue - * @return total message count - */ - public long getMessageCount(String queueName) { - Long mainCount = redisTemplate.opsForList().size(keys.queueKey(queueName)); - Long processingCount = redisTemplate.opsForList().size(keys.processingKey(queueName)); - - return (mainCount != null ? mainCount : 0L) + - (processingCount != null ? processingCount : 0L); - } - - /** - * Gets the number of messages in the dead letter queue. - * - * @param queueName the main queue name - * @return dead letter queue message count - */ - public long getDeadLetterCount(String queueName) { - String dlqName = getDeadLetterQueueName(queueName); - Long dlqCount = redisTemplate.opsForList().size(keys.deadLetterKey(dlqName)); - - return dlqCount != null ? dlqCount : 0L; - } - - // Summary record - public record QueueSummary( - long totalMessages, - long totalProcessing, - long totalPending, - int activeQueues - ) { - } - - private record QueueMetadata(String queueName, Class payloadType, MessageHandler handler, int maxRetries, - String deadLetterQueue) { - } - -} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/workbench/TimelineOverrideService.java b/src/main/java/com/dedicatedcode/reitti/service/workbench/TimelineOverrideService.java new file mode 100644 index 000000000..85be1b296 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/workbench/TimelineOverrideService.java @@ -0,0 +1,129 @@ +package com.dedicatedcode.reitti.service.workbench; + +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class TimelineOverrideService { + private final JdbcTemplate template; + + public TimelineOverrideService(JdbcTemplate template) { + this.template = template; + } + + @Transactional + public void setTimelineOverride(User user, Device device, Instant start, Instant end) { + Long userId = user.getId(); + Timestamp startTs = Timestamp.from(start); + Timestamp endTs = Timestamp.from(end); + + if (device == null) { + // Clear all overrides overlapping this time range + template.update( + "DELETE FROM timeline_overrides WHERE user_id = ? AND tstzrange(start_time, end_time) && tstzrange(?, ?)", + userId, startTs, endTs + ); + return; + } + + Long deviceId = device.id(); + + // Fetch all overlapping rows for the user + List> overlappingRows = template.queryForList( + "SELECT id, device_id, start_time, end_time FROM timeline_overrides WHERE user_id = ? AND tstzrange(start_time, end_time) && tstzrange(?, ?)", + userId, startTs, endTs + ); + + List> sameDeviceRows = new ArrayList<>(); + List> otherDeviceRows = new ArrayList<>(); + + for (Map row : overlappingRows) { + Long existingDeviceId = (Long) row.get("device_id"); + if (existingDeviceId.equals(deviceId)) { + sameDeviceRows.add(row); + } else { + otherDeviceRows.add(row); + } + } + + // Handle same device rows: merge into a single range + if (!sameDeviceRows.isEmpty()) { + Instant minStart = start; + Instant maxEnd = end; + for (Map row : sameDeviceRows) { + Instant rowStart = ((Timestamp) row.get("start_time")).toInstant(); + Instant rowEnd = ((Timestamp) row.get("end_time")).toInstant(); + if (rowStart.isBefore(minStart)) minStart = rowStart; + if (rowEnd.isAfter(maxEnd)) maxEnd = rowEnd; + // Delete the old row + template.update("DELETE FROM timeline_overrides WHERE id = ?", row.get("id")); + } + // Insert the merged row + template.update( + "INSERT INTO timeline_overrides (user_id, device_id, start_time, end_time) VALUES (?, ?, ?, ?)", + userId, deviceId, Timestamp.from(minStart), Timestamp.from(maxEnd) + ); + } + + // Handle other device rows: trim/split to accommodate the new range + for (Map row : otherDeviceRows) { + Long id = (Long) row.get("id"); + Long existingDeviceId = (Long) row.get("device_id"); + Timestamp rowStart = (Timestamp) row.get("start_time"); + Timestamp rowEnd = (Timestamp) row.get("end_time"); + + Instant rowStartInstant = rowStart.toInstant(); + Instant rowEndInstant = rowEnd.toInstant(); + + // Case 1: existing row fully contains the new range → split + if (rowStartInstant.isBefore(start) && rowEndInstant.isAfter(end)) { + template.update("DELETE FROM timeline_overrides WHERE id = ?", id); + // left part + template.update( + "INSERT INTO timeline_overrides (user_id, device_id, start_time, end_time) VALUES (?, ?, ?, ?)", + userId, existingDeviceId, rowStart, startTs + ); + // right part + template.update( + "INSERT INTO timeline_overrides (user_id, device_id, start_time, end_time) VALUES (?, ?, ?, ?)", + userId, existingDeviceId, endTs, rowEnd + ); + } + // Case 2: overlap only at the start of the existing row → trim end + else if (rowStartInstant.isBefore(start) && rowEndInstant.isAfter(start) && !rowEndInstant.isAfter(end)) { + template.update( + "UPDATE timeline_overrides SET end_time = ? WHERE id = ?", + startTs, id + ); + } + // Case 3: overlap only at the end of the existing row → trim start + else if (rowStartInstant.isBefore(end) && rowEndInstant.isAfter(end) && !rowStartInstant.isBefore(start)) { + template.update( + "UPDATE timeline_overrides SET start_time = ? WHERE id = ?", + endTs, id + ); + } + // Case 4: existing row fully inside the new range → delete + else { + template.update("DELETE FROM timeline_overrides WHERE id = ?", id); + } + } + + // If we did not already insert a merged same-device row, insert the new override + if (sameDeviceRows.isEmpty()) { + template.update( + "INSERT INTO timeline_overrides (user_id, device_id, start_time, end_time) VALUES (?, ?, ?, ?)", + userId, deviceId, startTs, endTs + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/service/workbench/WorkbenchService.java b/src/main/java/com/dedicatedcode/reitti/service/workbench/WorkbenchService.java new file mode 100644 index 000000000..77e925ed4 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/workbench/WorkbenchService.java @@ -0,0 +1,117 @@ +package com.dedicatedcode.reitti.service.workbench; + +import com.dedicatedcode.reitti.dto.workbench.*; +import com.dedicatedcode.reitti.model.devices.Device; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.DeviceJdbcService; +import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService; +import com.dedicatedcode.reitti.service.I18nService; +import com.dedicatedcode.reitti.service.jobs.JobSchedulingService; +import com.dedicatedcode.reitti.service.jobs.JobType; +import com.dedicatedcode.reitti.service.processing.DeviceTimeRange; +import com.dedicatedcode.reitti.service.processing.LocationDataCleanupJob; +import com.dedicatedcode.reitti.service.processing.PatchDeviceOntoTimelineJob; +import com.github.kagkarlsson.scheduler.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +@Service +public class WorkbenchService { + private static final Logger log = LoggerFactory.getLogger(WorkbenchService.class); + private final SourceLocationPointJdbcService sourceLocationPointJdbcService; + private final DeviceJdbcService deviceJdbcService; + private final JobSchedulingService jobSchedulingService; + private final Task locationDataCleanupTask; + private final Task patchDeviceOntoTimelineTask; + private final I18nService i18n; + + public WorkbenchService(SourceLocationPointJdbcService sourceLocationPointJdbcService, + DeviceJdbcService deviceJdbcService, + JobSchedulingService jobSchedulingService, + Task locationDataCleanupTask, + Task patchDeviceOntoTimelineTask, + I18nService i18n) { + this.sourceLocationPointJdbcService = sourceLocationPointJdbcService; + this.deviceJdbcService = deviceJdbcService; + this.jobSchedulingService = jobSchedulingService; + this.locationDataCleanupTask = locationDataCleanupTask; + this.patchDeviceOntoTimelineTask = patchDeviceOntoTimelineTask; + this.i18n = i18n; + } + + public void applyCommit(User user, WorkbenchCommitRequest request) { + log.debug("Applying commit {}", request); + EditStoreDto editStore = request.getEditStore(); + UUID parentJob = this.jobSchedulingService.createParentJob(user, JobType.MANUAL_MODIFICATION, "Updating data for " + user.getUsername()); + + if (editStore.getDeletedPoints() != null) { + handleDeletion(user, editStore, parentJob); + } + if (editStore.getMovedPoints() != null) { + handleMove(user, editStore, parentJob); + } + if (editStore.getPatches() != null) { + handlePatches(user, editStore.getPatches(), parentJob); + } + } + + private void handlePatches(User user, List patches, UUID parentJob) { + log.debug("Handling patches for user [{}] and [{}] patches", user.getUsername(), patches.size()); + patches.stream().sorted(Comparator.comparing(PatchDto::getSeq)) + .forEachOrdered(patchDto -> { + Device device = this.deviceJdbcService.find(user, Long.valueOf(patchDto.getDeviceId())).orElseThrow(); + Instant start = Instant.ofEpochMilli(patchDto.gettStart()); + Instant end = Instant.ofEpochMilli(patchDto.gettEnd()); + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.TIMELINE_STITCHING) + .friendlyName(i18n.translate("jobs.timeline_stitching.friendly_name", device.name(), start, end)).build(); + this.jobSchedulingService.enqueueTask(patchDeviceOntoTimelineTask, + new PatchDeviceOntoTimelineJob.TaskData(user, device, start, end).withParentJobId(parentJob), + metadata); + }); + } + + private void handleDeletion(User user, EditStoreDto editStore, UUID parentJob) { + log.debug("Handling deletion for user [{}] and [{}] points", user.getUsername(), editStore.getDeletedPoints().size()); + List deletedPoints = editStore.getDeletedPoints(); + List deletedPointIds = deletedPoints.stream().map(DeletedPointDto::getSourceId).toList(); + List affectedTimeRange = this.sourceLocationPointJdbcService.findAffectedTimeRange(user, deletedPointIds); + this.sourceLocationPointJdbcService.bulkUpdateManuallyIgnoredStatus(user, deletedPointIds); + scheduleUpdateJob(user, parentJob, affectedTimeRange); + } + + private void handleMove(User user, EditStoreDto editStore, UUID parentJob) { + log.debug("Handling move for user [{}] and [{}] points", user.getUsername(), editStore.getMovedPoints().size()); + List movedPoints = editStore.getMovedPoints(); + List movedPointIds = movedPoints.stream().map(MovedPointDto::getSourceId).toList(); + List affectedTimeRange = this.sourceLocationPointJdbcService.findAffectedTimeRange(user, movedPointIds); + for (MovedPointDto movedPoint : movedPoints) { + this.sourceLocationPointJdbcService.updateLocation(user, movedPoint.getSourceId(), movedPoint.getLat(), movedPoint.getLng()); + } + scheduleUpdateJob(user, parentJob, affectedTimeRange); + } + + private void scheduleUpdateJob(User user, UUID parentJob, List affectedTimeRange) { + for (DeviceTimeRange deviceTimeRange : affectedTimeRange) { + Device device = this.deviceJdbcService.find(user, deviceTimeRange.deviceId()).orElseThrow(); + JobSchedulingService.Metadata metadata = JobSchedulingService.Metadata.builder() + .user(user) + .jobType(JobType.LOCATION_DATA_CLEANUP) + .friendlyName("Location Data Cleanup") + .build(); + this.jobSchedulingService.enqueueTask(locationDataCleanupTask, + new LocationDataCleanupJob.TaskData(user, device, deviceTimeRange.timeRange().start(), deviceTimeRange.timeRange().end().plus(1, ChronoUnit.MILLIS)) + .withParentJobId(parentJob), + metadata); + } + } + +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index fd9940c8d..526b05c97 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -18,7 +18,8 @@ reitti.server.advertise-uri=http://localhost:8080 reitti.ui.tiles.cache.url=http://localhost:8084 reitti.security.local-login.disable=false -reitti.process-data.schedule=- + +reitti.process-data.refresh-views.schedule=0 * * * * * reitti.storage.path=data/ diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index 16874bfee..7cd8a7a48 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -29,12 +29,8 @@ reitti.server.advertise-uri=${ADVERTISE_URI:} reitti.data-management.enabled=${DANGEROUS_LIFE:false} -reitti.import.processing-idle-start-time=${PROCESSING_WAIT_TIME:15} - reitti.geocoding.photon.base-url=${PHOTON_BASE_URL:} -reitti.process-data.schedule=${REITTI_PROCESS_DATA_CRON:0 */10 * * * *} - reitti.ui.tiles.custom.service=${CUSTOM_TILES_SERVICE:} reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:} reitti.ui.tiles.cache.url=${TILES_CACHE:http://tile-cache} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9ca257e37..edb3115c7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -36,20 +36,18 @@ spring.data.redis.password= spring.data.redis.database=0 spring.cache.redis.key-prefix= -spring.cache.cache-names=processed-visits,significant-places,users,magic-links,configurations,transport-mode-configs,avatarThumbnails,avatarData,user-settings +spring.cache.cache-names=processed-visits,significant-places,users,magic-links,configurations,transport-mode-configs,avatarThumbnails,avatarData,user-settings, devices, mapStyles, mapStyleJson spring.cache.redis.time-to-live=1d # Upload configuration spring.servlet.multipart.max-file-size=5GB spring.servlet.multipart.max-request-size=5GB +spring.servlet.multipart.resolve-lazily=true server.tomcat.max-part-count=100 -# disable rqueue persistent job storage -rqueue.web.enable=false -rqueue.job.enabled=false -rqueue.message.durability.in-terminal-state=0 -rqueue.key.prefix=${spring.cache.redis.key-prefix} -rqueue.message.converter.provider.class=com.dedicatedcode.reitti.config.RQueueCustomMessageConverter +# DB-Scheduler configuration + +db-scheduler.immediate-execution-enabled=true # Application-specific settings reitti.server.advertise-uri= @@ -62,19 +60,21 @@ reitti.security.oidc.enabled=false reitti.security.oidc.registration.enabled=true reitti.import.batch-size=10000 +reitti.import.staging.cleanup.cron=0 0 4 * * * # How many seconds should we wait after the last data input before starting to process all unprocessed data? -reitti.import.processing-idle-start-time=10 +reitti.import.grace-time-seconds=30 + +reitti.jobs.cleanup.cron=0 0 4 * * ? +reitti.jobs.cleanup.max-age-hours=24 reitti.geo-point-filter.max-speed-kmh=1000 reitti.geo-point-filter.max-accuracy-meters=100 reitti.geo-point-filter.history-lookback-hours=24 reitti.geo-point-filter.window-size=50 -reitti.process-data.schedule=0 */10 * * * * reitti.process-data.refresh-views.schedule=0 0 4 * * * reitti.imports.schedule=0 5/10 * * * * - reitti.imports.owntracks-recorder.schedule=${reitti.imports.schedule} # Geocoding service configuration reitti.geocoding.max-errors=10 @@ -107,4 +107,3 @@ reitti.logging.max-buffer-size=10000 # For OIDC security configuration, create a separate oidc.properties file instead of configuring OIDC settings directly in this file. See the oidc.properties.example for the needed properties. spring.config.import=optional:oidc.properties - diff --git a/src/main/resources/db/migration/V100__add_user_map_styles.sql b/src/main/resources/db/migration/V100__add_user_map_styles.sql new file mode 100644 index 000000000..95fcb17a3 --- /dev/null +++ b/src/main/resources/db/migration/V100__add_user_map_styles.sql @@ -0,0 +1,43 @@ +CREATE TABLE user_map_styles +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + map_type VARCHAR(32) NOT NULL DEFAULT 'vector', + style_input_type VARCHAR(32) NOT NULL DEFAULT 'url', + raster_source_input_type VARCHAR(32) NOT NULL DEFAULT 'tile_template', + style_json TEXT, + style_url TEXT, + source_id VARCHAR(255), + source_type VARCHAR(32) NOT NULL DEFAULT 'vector', + tilejson_url TEXT, + tile_url_template TEXT, + attribution TEXT, + minzoom INTEGER, + maxzoom INTEGER, + tile_size INTEGER, + scheme VARCHAR(8), + attribution_override TEXT, + glyphs_url_override TEXT, + sprite_url_override TEXT, + shared BOOLEAN NOT NULL DEFAULT FALSE, + proxy_tiles BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 1, + CONSTRAINT user_map_styles_style_source CHECK ( + (map_type = 'vector' AND (style_json IS NOT NULL OR style_url IS NOT NULL)) + OR + (map_type = 'raster' AND (tilejson_url IS NOT NULL OR tile_url_template IS NOT NULL)) + ) +); + +CREATE INDEX idx_user_map_styles_user_id ON user_map_styles (user_id); +CREATE INDEX idx_user_map_styles_shared ON user_map_styles (shared) WHERE shared = TRUE; + +CREATE TABLE user_map_style_settings +( + user_id BIGINT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + active_style_id VARCHAR(255) NOT NULL DEFAULT 'reitti', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/main/resources/static/map/reitti.json b/src/main/resources/db/migration/V101__add_default_styles_to_database.sql similarity index 59% rename from src/main/resources/static/map/reitti.json rename to src/main/resources/db/migration/V101__add_default_styles_to_database.sql index c85a0268a..0d896ab8f 100644 --- a/src/main/resources/static/map/reitti.json +++ b/src/main/resources/db/migration/V101__add_default_styles_to_database.sql @@ -1,4 +1,11 @@ -{ +ALTER TABLE user_map_styles + ADD COLUMN default_style BOOLEAN DEFAULT FALSE; + +CREATE TEMP TABLE tmp_default_style_ids (id BIGINT, name TEXT); + +WITH inserted AS ( + INSERT INTO user_map_styles (name, user_id, map_type, style_input_type, style_json, default_style, shared, proxy_tiles) + VALUES ('Reitti', (SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1), 'vector', 'json', '{ "version": 8, "name": "Bright Faded", "metadata": { @@ -19,7 +26,7 @@ "dedicatedcode": { "type": "vector", "url": "https://tiles.dedicatedcode.com/planet", - "attribution": "© OpenFreeMap © OSM", + "attribution": "© OpenFreeMap © OSM", "maxzoom": 14, "minzoom": 0 }, @@ -31,7 +38,7 @@ "tileSize": 256, "encoding": "terrarium", "maxzoom": 14, - "attribution": "© Mapterhorn" + "attribution": "© Mapterhorn" }, "satellite-source": { "type": "raster", @@ -40,7 +47,7 @@ ], "tileSize": 256, "maxzoom": 18, - "attribution": "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" + "attribution": "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" } }, "terrain": { @@ -369,6 +376,7 @@ "id": "satellite-layer", "type": "raster", "source": "satellite-source", + "visibility": "none", "paint": { "raster-opacity": 0, "raster-opacity-transition": { @@ -6205,4 +6213,2696 @@ } ], "id": "bright" -} \ No newline at end of file +}', TRUE, TRUE, TRUE) + RETURNING id, name +) +INSERT INTO tmp_default_style_ids (id, name) SELECT id, name FROM inserted; + +WITH inserted AS ( + INSERT INTO user_map_styles (name, user_id, map_type, style_input_type, style_json, default_style, shared, proxy_tiles) + VALUES ('Reitti (Colored)', (SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1), 'vector', 'json', '{ + "version": 8, + "name": "Bright", + "metadata": { + + "mapbox:type": "template", + "maputnik:renderer": "mlgljs" + }, + "center": [0, 0], + "zoom": 1, + "bearing": 0, + "pitch": 0, + "sources": { + "dedicatedcode": { + "type": "vector", + "url": "https://tiles.dedicatedcode.com/planet", + "attribution": "© OpenFreeMap © OSM", + "maxzoom":14, + "minzoom":0 + }, + "terrain-source": { + "type": "raster-dem", + "tiles": [ + "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp" + ], + "tileSize": 256, + "encoding": "terrarium", + "maxzoom": 14, + "attribution": "© Mapterhorn" + }, + "satellite-source": { + "type": "raster", + "tiles": [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + ], + "tileSize": 256, + "maxzoom": 18, + "attribution": "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" + } + }, + "terrain": { + "source": "terrain-source", + "exaggeration": 1 + }, + "fog": { + "range": [0.5, 10], + "color": "#144272", + "high-color": "#010b19", + "space-color": "#010b19", + "horizon-blend": 0.1 + }, + "light": { + "anchor": "viewport", + "color": "#f0f9ff", + "intensity": 0.3, + "position": [1.15, 210, 30] + }, + "sky": { + "sky-color": "#010409", + "horizon-color": "#144272", + "fog-color": "#010409", + "fog-ground-blend": 0.5, + "horizon-fog-blend": 0.95, + "sky-horizon-blend": 0.7, + "atmosphere-blend": [ + "interpolate", ["linear"], ["zoom"], + 2, 1.0, + 6, 0.0 + ] + }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", + "sprite": "https://openmaptiles.github.io/osm-bright-gl-style/sprite", + "layers": [ + { + "id": "background", + "type": "background", + "paint": {"background-color": "#f8f4f0"} + }, + { + "id": "landcover-glacier", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": ["==", "subclass", "glacier"], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#fff", + "fill-opacity": {"base": 1, "stops": [[0, 0.9], [10, 0.3]]} + } + }, + { + "id": "landuse-residential", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + ["in", "class", "residential", "suburb", "neighbourhood"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [12, "hsla(30, 19%, 90%, 0.4)"], + [16, "hsla(30, 19%, 90%, 0.2)"] + ] + } + } + }, + { + "id": "landuse-commercial", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["==", "class", "commercial"] + ], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsla(0, 60%, 87%, 0.23)"} + }, + { + "id": "landuse-industrial", + "type": "fill", + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["in", "class", "industrial", "garages", "dam"] + ], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsla(49, 100%, 88%, 0.34)"} + }, + { + "id": "landuse-cemetery", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": ["==", "class", "cemetery"], + "paint": {"fill-color": "#e0e4dd"} + }, + { + "id": "landuse-hospital", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": ["==", "class", "hospital"], + "paint": {"fill-color": "#fde"} + }, + { + "id": "landuse-school", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": ["==", "class", "school"], + "paint": {"fill-color": "#f0e8f8"} + }, + { + "id": "landuse-railway", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": ["==", "class", "railway"], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsla(30, 19%, 90%, 0.4)"} + }, + { + "id": "landcover-wood", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": ["==", "class", "wood"], + "paint": { + "fill-antialias": { + "base": 1, + "stops": [[0, false], [9, true]] + }, + "fill-color": "rgba(25, 176, 16, 0.23)", + "fill-opacity": 1, + "fill-outline-color": "rgba(0, 0, 0, 0)" + }, + "layout": {"visibility": "visible"} + }, + { + "id": "landcover-grass", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": ["==", "class", "grass"], + "paint": {"fill-color": "#d8e8c8", "fill-opacity": 1} + }, + { + "id": "landcover-grass-park", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "dedicatedcode", + "source-layer": "park", + "filter": ["==", "class", "public_park"], + "paint": {"fill-color": "#d8e8c8", "fill-opacity": 0.8} + }, + { + "id": "satellite-layer", + "type": "raster", + "source": "satellite-source", + "visibility": "none", + "paint": { + "raster-opacity": 0, + "raster-opacity-transition": { + "duration": 500 + } + }, + "layout": {"visibility": "visible"} + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "dedicatedcode", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + ["in", "class", "river", "stream", "canal"], + ["==", "brunnel", "tunnel"] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [2, 4], + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} + } + }, + { + "id": "waterway-other", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 0] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 2]]} + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 1] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [4, 3], + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 2]]} + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [4, 3], + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} + } + }, + { + "id": "waterway-river", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-width": {"base": 1.2, "stops": [[10, 0.8], [20, 6]]} + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [3, 2.5], + "line-width": {"base": 1.2, "stops": [[10, 0.8], [20, 6]]} + } + }, + { + "id": "water-offset", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "water", + "maxzoom": 8, + "filter": ["==", "$type", "Polygon"], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#a0c8f0", + "fill-opacity": 1, + "fill-translate": {"base": 1, "stops": [[6, [2, 0]], [8, [0, 0]]]} + } + }, + { + "id": "water", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "water", + "filter": ["all", ["!=", "intermittent", 1], ["!=", "brunnel", "tunnel"]], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsl(210, 67%, 85%)"} + }, + { + "id": "water-intermittent", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "water", + "filter": ["all", ["==", "intermittent", 1]], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsl(210, 67%, 85%)", "fill-opacity": 0.7} + }, + { + "id": "water-pattern", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "water", + "filter": ["all"], + "layout": {"visibility": "visible"}, + "paint": {"fill-pattern": "wave", "fill-translate": [0, 2.5]} + }, + { + "id": "hillshading", + "type": "hillshade", + "paint": { + "hillshade-exaggeration": 1, + "hillshade-shadow-color": [ + "rgba(138, 138, 138, 1)" + ], + "hillshade-method": "igor", + "hillshade-illumination-anchor": "viewport", + "hillshade-highlight-color": [ + "rgba(255, 255, 255, 1)" + ] + }, + "layout": { + "visibility": "visible" + }, + "source": "terrain-source", + "maxzoom": 24 + }, + { + "id": "landcover-ice-shelf", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": ["==", "subclass", "ice_shelf"], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#fff", + "fill-opacity": {"base": 1, "stops": [[0, 0.9], [10, 0.3]]} + } + }, + { + "id": "landcover-sand", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": ["==", "class", "sand"], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "rgba(245, 238, 188, 1)", "fill-opacity": 1} + }, + { + "id": "building", + "type": "fill", + "metadata": {"mapbox:group": "1444849364238.8171"}, + "source": "dedicatedcode", + "source-layer": "building", + "paint": { + "fill-antialias": true, + "fill-color": {"base": 1, "stops": [[15.5, "#f2eae2"], [16, "#dfdbd7"]]} + } + }, + { + "id": "building-top", + "type": "fill", + "metadata": {"mapbox:group": "1444849364238.8171"}, + "source": "dedicatedcode", + "source-layer": "building", + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#f2eae2", + "fill-opacity": {"base": 1, "stops": [[13, 0], [16, 1]]}, + "fill-outline-color": "#dfdbd7", + "fill-translate": {"base": 1, "stops": [[14, [0, 0]], [16, [-2, -2]]]} + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-dasharray": [0.5, 0.25], + "line-width": {"base": 1.2, "stops": [[15, 1], [16, 4], [20, 11]]} + } + }, + { + "id": "tunnel-motorway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "rgba(200, 147, 102, 1)", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 4], [20, 15]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[8, 1.5], [20, 17]]}, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-trunk-primary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "#e9ac77", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "tunnel-path-steps-casing", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "tunnel"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-cap": "butt", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 2], [20, 9.25]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "tunnel"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-join": "bevel", "line-cap": "butt"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "tunnel"], + ["==", "class", "path"], + ["!=", "subclass", "steps"] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} + } + }, + { + "id": "tunnel-motorway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "rgba(244, 209, 158, 1)", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-width": {"base": 1.2, "stops": [[15.5, 0], [16, 2], [20, 7.5]]} + } + }, + { + "id": "tunnel-link", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff4c6", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 10]]} + } + }, + { + "id": "tunnel-trunk-primary", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff4c6", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "#ffdaa6", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "tunnel-railway", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [2, 2], + "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} + } + }, + { + "id": "ferry", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["in", "class", "ferry"]], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "rgba(108, 159, 182, 1)", + "line-dasharray": [2, 2], + "line-width": 1.1 + } + }, + { + "id": "aeroway-taxiway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "taxiway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": {"base": 1.5, "stops": [[11, 2], [17, 12]]} + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "runway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": {"base": 1.5, "stops": [[11, 5], [17, 55]]} + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["in", "class", "runway", "taxiway"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": {"base": 1, "stops": [[13, 0], [14, 1]]} + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["in", "class", "taxiway"], + ["==", "$type", "LineString"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": {"base": 1, "stops": [[11, 0], [12, 1]]}, + "line-width": {"base": 1.5, "stops": [[11, 1], [17, 10]]} + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["in", "class", "runway"], + ["==", "$type", "LineString"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": {"base": 1, "stops": [[11, 0], [12, 1]]}, + "line-width": {"base": 1.5, "stops": [[11, 4], [17, 50]]} + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["==", "class", "pier"]], + "layout": {"visibility": "visible"}, + "paint": {"fill-antialias": true, "fill-color": "#f8f4f0"} + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "pier"]], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#f8f4f0", + "line-width": {"base": 1.2, "stops": [[15, 1], [17, 4]]} + } + }, + { + "id": "highway-area", + "type": "fill", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["!in", "class", "pier"]], + "layout": {"visibility": "visible"}, + "paint": { + "fill-antialias": false, + "fill-color": "hsla(0, 0%, 89%, 0.56)", + "fill-opacity": 0.9, + "fill-outline-color": "#cfcdca" + } + }, + { + "id": "highway-path-steps-casing", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "path"], + ["in", "subclass", "steps"] + ], + "layout": {"line-cap": "butt", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 2], [20, 9.25]] + } + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 4], [20, 15]] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[8, 1.5], [20, 17]]} + } + }, + { + "id": "highway-primary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": {"stops": [[7, 0], [8, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[7, 0], [8, 0.6], [9, 1.5], [20, 22]] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": {"stops": [[5, 0], [6, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[5, 0], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 4, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": {"stops": [[4, 0], [5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[4, 0], [5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "highway-path", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "path"], + ["!=", "subclass", "steps"] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} + } + }, + { + "id": "highway-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-join": "bevel", "line-cap": "butt"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "highway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [8, 0.5], [20, 13]]} + } + }, + { + "id": "highway-primary", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[8.5, 0], [9, 0.5], [20, 18]]} + } + }, + { + "id": "highway-trunk", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "highway-motorway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fc8", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "railway-transit", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "transit"], + ["!in", "brunnel", "tunnel"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [20, 1]]} + } + }, + { + "id": "railway-transit-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "transit"], + ["!in", "brunnel", "tunnel"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 2], [20, 6]]} + } + }, + { + "id": "railway-service", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "rail"], + ["has", "service"] + ], + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [20, 1]]} + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "rail"], + ["has", "service"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 2], [20, 6]]} + } + }, + { + "id": "railway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ], + "paint": { + "line-color": "#bbb", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} + } + }, + { + "id": "railway-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 3], [20, 8]]} + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 19]] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 19]] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [7, 0.6], [8, 1.5], [20, 21]] + } + } + }, + { + "id": "bridge-trunk-primary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "hsl(28, 76%, 67%)", + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 26]] + } + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 26]] + } + } + }, + { + "id": "bridge-minor-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "butt", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 6], [20, 24]] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["==", "class", "path"] + ], + "paint": { + "line-color": "#cfcdca", + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 18]]} + } + }, + { + "id": "bridge-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-join": "round", "line-cap": "butt"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["==", "class", "path"], + ["!=", "subclass", "steps"] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "bridge-minor", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [8, 0.5], [20, 13]]} + } + }, + { + "id": "bridge-trunk-primary", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fc8", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "bridge-railway", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 3], [20, 8]]} + } + }, + { + "id": "cablecar", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "subclass", "cable_car"], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-width": {"base": 1, "stops": [[11, 1], [19, 2.5]]} + } + }, + { + "id": "cablecar-dash", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "subclass", "cable_car"], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [2, 3], + "line-width": {"base": 1, "stops": [[11, 3], [19, 5.5]]} + } + }, + { + "id": "boundary-land-level-4", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "minzoom": 2, + "filter": [ + "all", + [">=", "admin_level", 3], + ["<=", "admin_level", 8], + ["!=", "maritime", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "#9e9cab", + "line-dasharray": [3, 1, 1, 1], + "line-width": {"base": 1.4, "stops": [[4, 0.4], [5, 1], [12, 3]]} + } + }, + { + "id": "boundary-land-level-2", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "filter": [ + "all", + ["==", "admin_level", 2], + ["!=", "maritime", 1], + ["!=", "disputed", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 66%)", + "line-width": { + "base": 1, + "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] + } + } + }, + { + "id": "boundary-land-disputed", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "filter": ["all", ["!=", "maritime", 1], ["==", "disputed", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 70%)", + "line-dasharray": [1, 3], + "line-width": { + "base": 1, + "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] + } + } + }, + { + "id": "boundary-water", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "minzoom": 4, + "filter": ["all", ["in", "admin_level", 2, 4], ["==", "maritime", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(154, 189, 214, 1)", + "line-opacity": {"stops": [[6, 0.6], [10, 1]]}, + "line-width": { + "base": 1, + "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "waterway", + "minzoom": 13, + "filter": ["all", ["==", "$type", "LineString"], ["has", "name"]], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-lakeline", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": ["==", "$type", "LineString"], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-ocean", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["==", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-other", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["!in", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": {"stops": [[0, 10], [6, 14]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "road_oneway", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", 1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": {"stops": [[15, 0.5], [19, 1]]}, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": {"icon-opacity": 0.5} + }, + { + "id": "road_oneway_opposite", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", -1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": {"stops": [[15, 0.5], [19, 1]]}, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": {"icon-opacity": 0.5} + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 16, + "filter": [ + "all", + ["==", "$type", "Point"], + [">=", "rank", 25], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 24], + [">=", "rank", 15], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 14, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 14], + ["has", "name"], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 13, + "filter": [ + "all", + ["==", "$type", "Point"], + ["has", "name"], + ["==", "class", "railway"], + ["==", "subclass", "station"] + ], + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-image": "{class}_11", + "icon-optional": false, + "text-allow-overlap": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-ignore-placement": false, + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12 + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 15.5, + "filter": ["==", "class", "path"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} + }, + "paint": { + "text-color": "hsl(30, 23%, 62%)", + "text-halo-color": "#f8f4f0", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["in", "class", "minor", "service", "track"] + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 12.2, + "filter": ["in", "class", "primary", "secondary", "tertiary", "trunk"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 8, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["!in", "network", "us-interstate", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": {"base": 1, "stops": [[10, "point"], [11, "line"]]}, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {} + }, + { + "id": "highway-shield-us-interstate", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 7, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-interstate"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [[7, "point"], [7, "line"], [8, "line"]] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {"text-color": "rgba(0, 0, 0, 1)"} + }, + { + "id": "highway-shield-us-other", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 9, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": {"base": 1, "stops": [[10, "point"], [11, "line"]]}, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {"text-color": "rgba(0, 0, 0, 1)"} + }, + { + "id": "airport-label-major", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "aerodrome_label", + "minzoom": 10, + "filter": ["all", ["has", "iata"]], + "layout": { + "icon-image": "airport_11", + "icon-size": 1, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "building-3d", + "type": "fill-extrusion", + "source": "dedicatedcode", + "source-layer": "building", + "minzoom": 1, + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-color": "rgba(209, 209, 209, 1)", + "fill-extrusion-height": { + "property": "render_height", + "type": "identity" + }, + "fill-extrusion-base": { + "property": "render_min_height", + "type": "identity" + }, + "fill-extrusion-opacity": 1, + "fill-extrusion-vertical-gradient": false, + "fill-extrusion-translate-anchor": "viewport" + } + }, + { + "id": "place-other", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "!in", + "class", + "city", + "town", + "village", + "state", + "country", + "continent" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Bold"], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": {"base": 1.2, "stops": [[12, 10], [15, 14]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": ["==", "class", "village"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": {"base": 1.2, "stops": [[10, 12], [15, 22]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": ["==", "class", "town"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": {"base": 1.2, "stops": [[10, 14], [15, 24]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": ["all", ["!=", "capital", 2], ["==", "class", "city"]], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": {"base": 1.2, "stops": [[7, 14], [11, 24]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city-capital", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": ["all", ["==", "capital", 2], ["==", "class", "city"]], + "layout": { + "icon-image": "star_11", + "icon-size": 0.8, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-offset": [0.4, 0], + "text-size": {"base": 1.2, "stops": [[7, 14], [11, 24]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-state", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": ["in", "class", "state"], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": {"base": 1.2, "stops": [[12, 10], [15, 14]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-country-other", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["!has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-max-width": 6.25, + "text-size": {"stops": [[3, 11], [7, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": {"stops": [[3, 11], [7, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 2], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": {"stops": [[2, 11], [5, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-1", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 1], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": {"stops": [[1, 11], [4, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-continent", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "dedicatedcode", + "source-layer": "place", + "maxzoom": 1, + "filter": ["==", "class", "continent"], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": 14, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + } + ], + "id": "bright" +}', true, true, true) + RETURNING id, name +) +INSERT INTO tmp_default_style_ids (id, name) SELECT id, name FROM inserted; + +INSERT INTO user_map_style_settings (user_id, active_style_id) +SELECT us.user_id, t.id +FROM user_settings us + CROSS JOIN tmp_default_style_ids t +WHERE us.prefer_colored_map = TRUE + AND t.name = 'Reitti (Colored)' +ON CONFLICT(user_id) DO UPDATE SET active_style_id = EXCLUDED.active_style_id; + +INSERT INTO user_map_style_settings (user_id, active_style_id) +SELECT us.user_id, t.id +FROM user_settings us + CROSS JOIN tmp_default_style_ids t +WHERE us.prefer_colored_map = FALSE + AND t.name = 'Reitti' +ON CONFLICT(user_id) DO UPDATE SET active_style_id = EXCLUDED.active_style_id; + +ALTER TABLE user_settings DROP COLUMN prefer_colored_map; \ No newline at end of file diff --git a/src/main/resources/db/migration/V102__adjust_column_types_for_user_map_styles.sql b/src/main/resources/db/migration/V102__adjust_column_types_for_user_map_styles.sql new file mode 100644 index 000000000..1fad6d7d3 --- /dev/null +++ b/src/main/resources/db/migration/V102__adjust_column_types_for_user_map_styles.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_map_style_settings DROP COLUMN active_style_id; +ALTER TABLE user_map_style_settings ADD COLUMN active_style_id BIGINT REFERENCES public.user_map_styles(id) DEFAULT 1; diff --git a/src/main/resources/db/migration/V103__update_default_reitti_map_styles.sql b/src/main/resources/db/migration/V103__update_default_reitti_map_styles.sql new file mode 100644 index 000000000..b10be02b9 --- /dev/null +++ b/src/main/resources/db/migration/V103__update_default_reitti_map_styles.sql @@ -0,0 +1,12416 @@ + +UPDATE user_map_styles SET style_json = '{ + "version": 8, + "name": "Bright Faded", + "metadata": { + "mapbox:type": "template", + "openmaptiles:mapbox:owner": "dedicatedcode", + "openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t", + "openmaptiles:version": "3.x", + "maputnik:renderer": "mlgljs" + }, + "center": [ + 0, + 0 + ], + "zoom": 1, + "bearing": 0, + "pitch": 0, + "sources": { + "dedicatedcode": { + "type": "vector", + "url": "https://tiles.dedicatedcode.com/planet", + "attribution": "© OpenFreeMap © OSM", + "maxzoom": 14, + "minzoom": 0 + }, + "terrain-source": { + "type": "raster-dem", + "tiles": [ + "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp" + ], + "tileSize": 256, + "encoding": "terrarium", + "maxzoom": 14, + "attribution": "© Mapterhorn" + }, + "satellite-source": { + "type": "raster", + "tiles": [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + ], + "tileSize": 256, + "maxzoom": 18, + "attribution": "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" + } + }, + "terrain": { + "source": "terrain-source", + "exaggeration": 1 + }, + "fog": { + "range": [ + 0.5, + 10 + ], + "color": "#4a4e53", + "high-color": "#212224", + "space-color": "#212224", + "horizon-blend": 0.1 + }, + "light": { + "anchor": "viewport", + "color": "#f7f8f9", + "intensity": 0.3, + "position": [ + 1.15, + 210, + 30 + ] + }, + "sky": { + "sky-color": "#1d1d1d", + "horizon-color": "#4a4e53", + "fog-color": "#1d1d1d", + "fog-ground-blend": 0.5, + "horizon-fog-blend": 0.95, + "sky-horizon-blend": 0.7, + "atmosphere-blend": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 2, + 1, + 6, + 0 + ] + }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", + "sprite": "https://openmaptiles.github.io/osm-bright-gl-style/sprite", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#f6f6f5" + } + }, + { + "id": "landcover-glacier", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "subclass", + "glacier" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#fff", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 0, + 0.9 + ], + [ + 10, + 0.3 + ] + ] + } + } + }, + { + "id": "landuse-residential", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + [ + "in", + "class", + "residential", + "suburb", + "neighbourhood" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 12, + "rgba(233, 233, 233, 0.4)" + ], + [ + 16, + "rgba(233, 233, 233, 0.2)" + ] + ] + } + } + }, + { + "id": "landuse-commercial", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "commercial" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(221, 217, 217, 0.23)" + } + }, + { + "id": "landuse-industrial", + "type": "fill", + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "in", + "class", + "industrial", + "garages", + "dam" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(244, 243, 239, 0.34)" + } + }, + { + "id": "landuse-cemetery", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "cemetery" + ], + "paint": { + "fill-color": "#e5e5e4" + } + }, + { + "id": "landuse-hospital", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "hospital" + ], + "paint": { + "fill-color": "#eee" + } + }, + { + "id": "landuse-school", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "school" + ], + "paint": { + "fill-color": "#eeeeef" + } + }, + { + "id": "landuse-railway", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "railway" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(233, 233, 233, 0.4)" + } + }, + { + "id": "landcover-wood", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "wood" + ], + "paint": { + "fill-antialias": { + "base": 1, + "stops": [ + [ + 0, + false + ], + [ + 9, + true + ] + ] + }, + "fill-color": "rgba(119, 133, 118, 0.23)", + "fill-opacity": 1, + "fill-outline-color": "rgba(26, 26, 26, 0)" + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "landcover-grass", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "grass" + ], + "paint": { + "fill-color": "#e2e3e1", + "fill-opacity": 1 + } + }, + { + "id": "landcover-grass-park", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "park", + "filter": [ + "==", + "class", + "public_park" + ], + "paint": { + "fill-color": "#e2e3e1", + "fill-opacity": 0.8 + } + }, + { + "id": "satellite-layer", + "type": "raster", + "source": "satellite-source", + "visibility": "none", + "paint": { + "raster-opacity": 0, + "raster-opacity-transition": { + "duration": 500 + } + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "dedicatedcode", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "river", + "stream", + "canal" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-dasharray": [ + 2, + 4 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-other", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "!in", + "class", + "canal", + "river", + "stream" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "!in", + "class", + "canal", + "river", + "stream" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-dasharray": [ + 4, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "in", + "class", + "canal", + "stream" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "in", + "class", + "canal", + "stream" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-dasharray": [ + 4, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-river", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "==", + "class", + "river" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.8 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "==", + "class", + "river" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4c8cb", + "line-dasharray": [ + 3, + 2.5 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.8 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "water-offset", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "maxzoom": 8, + "filter": [ + "==", + "$type", + "Polygon" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#c4c8cb", + "fill-opacity": 1, + "fill-translate": { + "base": 1, + "stops": [ + [ + 6, + [ + 2, + 0 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + } + } + }, + { + "id": "water", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "filter": [ + "all", + [ + "!=", + "intermittent", + 1 + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgb(214, 217, 219)" + } + }, + { + "id": "water-intermittent", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "filter": [ + "all", + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgb(214, 217, 219)", + "fill-opacity": 0.7 + } + }, + { + "id": "water-pattern", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "filter": [ + "all" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-pattern": "wave", + "fill-translate": [ + 0, + 2.5 + ] + } + }, + { + "id": "terrain-hillshade", + "type": "hillshade", + "paint": { + "hillshade-exaggeration": 1, + "hillshade-shadow-color": [ + "rgba(150, 150, 150, 1)" + ], + "hillshade-method": "igor", + "hillshade-illumination-anchor": "viewport", + "hillshade-highlight-color": [ + "rgba(255, 255, 255, 1)" + ] + }, + "layout": { + "visibility": "visible" + }, + "source": "terrain-source", + "maxzoom": 24 + }, + { + "id": "landcover-ice-shelf", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "subclass", + "ice_shelf" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#fff", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 0, + 0.9 + ], + [ + 10, + 0.3 + ] + ] + } + } + }, + { + "id": "landcover-sand", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "sand" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(237, 237, 232, 1)", + "fill-opacity": 1 + } + }, + { + "id": "building", + "type": "fill", + "metadata": { + "mapbox:group": "1444849364238.8171" + }, + "source": "dedicatedcode", + "source-layer": "building", + "paint": { + "fill-antialias": true, + "fill-color": { + "base": 1, + "stops": [ + [ + 15.5, + "#eeeded" + ], + [ + 16, + "#e0dfdf" + ] + ] + } + } + }, + { + "id": "building-top", + "type": "fill", + "metadata": { + "mapbox:group": "1444849364238.8171" + }, + "source": "dedicatedcode", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#eeeded", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 16, + 1 + ] + ] + }, + "fill-outline-color": "#e0dfdf", + "fill-translate": { + "base": 1, + "stops": [ + [ + 14, + [ + 0, + 0 + ] + ], + [ + 16, + [ + -2, + -2 + ] + ] + ] + } + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "service", + "track" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#d2d2d2", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 4 + ], + [ + 20, + 11 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(171, 166, 162, 1)", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "minor" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#d2d2d2", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 17 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-trunk-primary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4beb9", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "tunnel-path-steps-casing", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-color": "#d2d2d2", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 2 + ], + [ + 20, + 9.25 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-join": "bevel", + "line-cap": "butt" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 1.25 + ], + [ + 20, + 5.75 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "!=", + "subclass", + "steps" + ] + ], + "paint": { + "line-color": "#ccc", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(221, 217, 213, 1)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "service", + "track" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15.5, + 0 + ], + [ + 16, + 2 + ], + [ + 20, + 7.5 + ] + ] + } + } + }, + { + "id": "tunnel-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#f5f4ef", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "minor" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#f5f4ef", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 10 + ] + ] + } + } + }, + { + "id": "tunnel-trunk-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#f5f4ef", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e5e2dd", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "tunnel-railway", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#ccc", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "ferry", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "in", + "class", + "ferry" + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(154, 158, 160, 1)", + "line-dasharray": [ + 2, + 2 + ], + "line-width": 1.1 + } + }, + { + "id": "aeroway-taxiway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "in", + "class", + "taxiway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(163, 163, 163, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 2 + ], + [ + 17, + 12 + ] + ] + } + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(163, 163, 163, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 5 + ], + [ + 17, + 55 + ] + ] + } + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "in", + "class", + "runway", + "taxiway" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "class", + "taxiway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 11, + 0 + ], + [ + 12, + 1 + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 1 + ], + [ + 17, + 10 + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 11, + 0 + ], + [ + 12, + 1 + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 4 + ], + [ + 17, + 50 + ] + ] + } + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "pier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "#f6f6f5" + } + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "pier" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#f6f6f5", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + } + }, + { + "id": "highway-area", + "type": "fill", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "!in", + "class", + "pier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": false, + "fill-color": "rgba(230, 230, 230, 0.56)", + "fill-opacity": 0.9, + "fill-outline-color": "#d2d2d2" + } + }, + { + "id": "highway-path-steps-casing", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "in", + "subclass", + "steps" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-color": "#d2d2d2", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 2 + ], + [ + 20, + 9.25 + ] + ] + } + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#d2d2d2", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 17 + ] + ] + } + } + }, + { + "id": "highway-primary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": { + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 0.6 + ], + [ + 9, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": { + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 4, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": { + "stops": [ + [ + 4, + 0 + ], + [ + 5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 4, + 0 + ], + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-path", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "!=", + "subclass", + "steps" + ] + ], + "paint": { + "line-color": "#ccc", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "highway-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-join": "bevel", + "line-cap": "butt" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 1.25 + ], + [ + 20, + 5.75 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#ddd", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 8, + 0.5 + ], + [ + 20, + 13 + ] + ] + } + } + }, + { + "id": "highway-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 8.5, + 0 + ], + [ + 9, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "highway-trunk", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "highway-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ddd", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "railway-transit", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(193, 193, 193, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "railway-transit-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(193, 193, 193, 0.68)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "railway-service", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ], + "paint": { + "line-color": "rgba(193, 193, 193, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(193, 193, 193, 0.68)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "railway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!has", + "service" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#ccc", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "railway-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!has", + "service" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#ccc", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 19 + ] + ] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 19 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 7, + 0.6 + ], + [ + 8, + 1.5 + ], + [ + 20, + 21 + ] + ] + } + } + }, + { + "id": "bridge-trunk-primary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "rgb(193, 187, 181)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 26 + ] + ] + } + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#c4beb9", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 26 + ] + ] + } + } + }, + { + "id": "bridge-minor-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-color": "#d2d2d2", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 6 + ], + [ + 20, + 24 + ] + ] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "path" + ] + ], + "paint": { + "line-color": "#d2d2d2", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-join": "round", + "line-cap": "butt" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 1.25 + ], + [ + 20, + 5.75 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "path" + ], + [ + "!=", + "subclass", + "steps" + ] + ], + "paint": { + "line-color": "#ccc", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ddd", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-minor", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 8, + 0.5 + ], + [ + 20, + 13 + ] + ] + } + } + }, + { + "id": "bridge-trunk-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#eee", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#ddd", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-railway", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#ccc", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#ccc", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + } + }, + { + "id": "cablecar", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "==", + "subclass", + "cable_car" + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(187, 187, 187)", + "line-width": { + "base": 1, + "stops": [ + [ + 11, + 1 + ], + [ + 19, + 2.5 + ] + ] + } + } + }, + { + "id": "cablecar-dash", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "==", + "subclass", + "cable_car" + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(187, 187, 187)", + "line-dasharray": [ + 2, + 3 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 11, + 3 + ], + [ + 19, + 5.5 + ] + ] + } + } + }, + { + "id": "boundary-land-level-4", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "minzoom": 2, + "filter": [ + "all", + [ + ">=", + "admin_level", + 3 + ], + [ + "<=", + "admin_level", + 8 + ], + [ + "!=", + "maritime", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a8a8a9", + "line-dasharray": [ + 3, + 1, + 1, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 0.4 + ], + [ + 5, + 1 + ], + [ + 12, + 3 + ] + ] + } + } + }, + { + "id": "boundary-land-level-2", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "!=", + "maritime", + 1 + ], + [ + "!=", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(173, 173, 174)", + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "boundary-land-disputed", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "filter": [ + "all", + [ + "!=", + "maritime", + 1 + ], + [ + "==", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(183, 183, 184)", + "line-dasharray": [ + 1, + 3 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "boundary-water", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "admin_level", + 2, + 4 + ], + [ + "==", + "maritime", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(186, 189, 192, 1)", + "line-opacity": { + "stops": [ + [ + 6, + 0.6 + ], + [ + 10, + 1 + ] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "waterway", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "has", + "name" + ] + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#a8aeb3", + "text-halo-color": "rgba(255, 255, 255, 0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-lakeline", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": [ + "==", + "$type", + "LineString" + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#a8aeb3", + "text-halo-color": "rgba(255, 255, 255, 0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-ocean", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "ocean" + ] + ], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#a8aeb3", + "text-halo-color": "rgba(255, 255, 255, 0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-other", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "!in", + "class", + "ocean" + ] + ], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": { + "stops": [ + [ + 0, + 10 + ], + [ + 6, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#a8aeb3", + "text-halo-color": "rgba(255, 255, 255, 0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "road_oneway", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + 1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "road_oneway_opposite", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + -1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + ">=", + "rank", + 25 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "<=", + "rank", + 24 + ], + [ + ">=", + "rank", + 15 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "<=", + "rank", + 14 + ], + [ + "has", + "name" + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "has", + "name" + ], + [ + "==", + "class", + "railway" + ], + [ + "==", + "subclass", + "station" + ] + ], + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-image": "{class}_11", + "icon-optional": false, + "text-allow-overlap": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-ignore-placement": false, + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-optional": true, + "text-padding": 2, + "text-size": 12 + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 15.5, + "filter": [ + "==", + "class", + "path" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + } + }, + "paint": { + "text-color": "rgb(173, 171, 169)", + "text-halo-color": "#f6f6f5", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + } + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 12.2, + "filter": [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + } + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 8, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "network", + "us-interstate", + "us-highway", + "us-state" + ] + ], + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {} + }, + { + "id": "highway-shield-us-interstate", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 7, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "network", + "us-interstate" + ] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { + "text-color": "rgba(26, 26, 26, 1)" + } + }, + { + "id": "highway-shield-us-other", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 9, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "network", + "us-highway", + "us-state" + ] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { + "text-color": "rgba(26, 26, 26, 1)" + } + }, + { + "id": "airport-label-major", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "aerodrome_label", + "minzoom": 10, + "filter": [ + "all", + [ + "has", + "iata" + ] + ], + "layout": { + "icon-image": "airport_11", + "icon-size": 1, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-optional": true, + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#777", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "building-3d", + "type": "fill-extrusion", + "source": "dedicatedcode", + "source-layer": "building", + "minzoom": 1, + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-color": "rgba(214, 214, 214, 1)", + "fill-extrusion-height": { + "property": "render_height", + "type": "identity" + }, + "fill-extrusion-base": { + "property": "render_min_height", + "type": "identity" + }, + "fill-extrusion-opacity": 1, + "fill-extrusion-vertical-gradient": false, + "fill-extrusion-translate-anchor": "viewport" + } + }, + { + "id": "place-other", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "!in", + "class", + "city", + "town", + "village", + "state", + "country", + "continent" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 15, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#555", + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "==", + "class", + "village" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 12 + ], + [ + 15, + 22 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "==", + "class", + "town" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 14 + ], + [ + 15, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "!=", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ] + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 7, + 14 + ], + [ + 11, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city-capital", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ] + ], + "layout": { + "icon-image": "star_11", + "icon-size": 0.8, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0.4, + 0 + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 7, + 14 + ], + [ + 11, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-state", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "in", + "class", + "state" + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 15, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#555", + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-country-other", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 3 + ], + [ + "!has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 7, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-blur": 1, + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 3 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 7, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-blur": 1, + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "==", + "rank", + 2 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 2, + 11 + ], + [ + 5, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-blur": 1, + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-1", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "==", + "rank", + 1 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 11 + ], + [ + 4, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-blur": 1, + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-continent", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "maxzoom": 1, + "filter": [ + "==", + "class", + "continent" + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": 14, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#444", + "text-halo-blur": 1, + "text-halo-color": "rgba(255, 255, 255, 0.8)", + "text-halo-width": 2 + } + } + ], + "id": "bright" +}' WHERE name = 'Reitti'; +UPDATE user_map_styles SET style_json = '{ + "version": 8, + "name": "Bright", + "metadata": { + "mapbox:type": "template", + "maputnik:renderer": "mlgljs" + }, + "center": [ + 0, + 0 + ], + "zoom": 1, + "bearing": 0, + "pitch": 0, + "sources": { + "dedicatedcode": { + "type": "vector", + "url": "https://tiles.dedicatedcode.com/planet", + "attribution": "© OpenFreeMap © OSM", + "maxzoom": 14, + "minzoom": 0 + }, + "terrain-source": { + "type": "raster-dem", + "tiles": [ + "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp" + ], + "tileSize": 256, + "encoding": "terrarium", + "maxzoom": 14, + "attribution": "© Mapterhorn" + }, + "satellite-source": { + "type": "raster", + "tiles": [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + ], + "tileSize": 256, + "maxzoom": 18, + "attribution": "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" + } + }, + "terrain": { + "source": "terrain-source", + "exaggeration": 1 + }, + "fog": { + "range": [ + 0.5, + 10 + ], + "color": "#144272", + "high-color": "#010b19", + "space-color": "#010b19", + "horizon-blend": 0.1 + }, + "light": { + "anchor": "viewport", + "color": "#f0f9ff", + "intensity": 0.3, + "position": [ + 1.15, + 210, + 30 + ] + }, + "sky": { + "sky-color": "#010409", + "horizon-color": "#144272", + "fog-color": "#010409", + "fog-ground-blend": 0.5, + "horizon-fog-blend": 0.95, + "sky-horizon-blend": 0.7, + "atmosphere-blend": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 2, + 1.0, + 6, + 0.0 + ] + }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", + "sprite": "https://openmaptiles.github.io/osm-bright-gl-style/sprite", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#f8f4f0" + } + }, + { + "id": "landcover-glacier", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "subclass", + "glacier" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#fff", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 0, + 0.9 + ], + [ + 10, + 0.3 + ] + ] + } + } + }, + { + "id": "landuse-residential", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + [ + "in", + "class", + "residential", + "suburb", + "neighbourhood" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 12, + "hsla(30, 19%, 90%, 0.4)" + ], + [ + 16, + "hsla(30, 19%, 90%, 0.2)" + ] + ] + } + } + }, + { + "id": "landuse-commercial", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "commercial" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsla(0, 60%, 87%, 0.23)" + } + }, + { + "id": "landuse-industrial", + "type": "fill", + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "in", + "class", + "industrial", + "garages", + "dam" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsla(49, 100%, 88%, 0.34)" + } + }, + { + "id": "landuse-cemetery", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "cemetery" + ], + "paint": { + "fill-color": "#e0e4dd" + } + }, + { + "id": "landuse-hospital", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "hospital" + ], + "paint": { + "fill-color": "#fde" + } + }, + { + "id": "landuse-school", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "school" + ], + "paint": { + "fill-color": "#f0e8f8" + } + }, + { + "id": "landuse-railway", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "railway" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsla(30, 19%, 90%, 0.4)" + } + }, + { + "id": "landcover-wood", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "wood" + ], + "paint": { + "fill-antialias": { + "base": 1, + "stops": [ + [ + 0, + false + ], + [ + 9, + true + ] + ] + }, + "fill-color": "rgba(25, 176, 16, 0.23)", + "fill-opacity": 1, + "fill-outline-color": "rgba(0, 0, 0, 0)" + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "landcover-grass", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "grass" + ], + "paint": { + "fill-color": "#d8e8c8", + "fill-opacity": 1 + } + }, + { + "id": "landcover-grass-park", + "type": "fill", + "metadata": { + "mapbox:group": "1444849388993.3071" + }, + "source": "dedicatedcode", + "source-layer": "park", + "filter": [ + "==", + "class", + "public_park" + ], + "paint": { + "fill-color": "#d8e8c8", + "fill-opacity": 0.8 + } + }, + { + "id": "satellite-layer", + "type": "raster", + "source": "satellite-source", + "visibility": "none", + "paint": { + "raster-opacity": 0, + "raster-opacity-transition": { + "duration": 500 + } + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "dedicatedcode", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "river", + "stream", + "canal" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 2, + 4 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-other", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "!in", + "class", + "canal", + "river", + "stream" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "!in", + "class", + "canal", + "river", + "stream" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 4, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "in", + "class", + "canal", + "stream" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "in", + "class", + "canal", + "stream" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 4, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-river", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "==", + "class", + "river" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.8 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "waterway", + "filter": [ + "all", + [ + "==", + "class", + "river" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 3, + 2.5 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.8 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "water-offset", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "maxzoom": 8, + "filter": [ + "==", + "$type", + "Polygon" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#a0c8f0", + "fill-opacity": 1, + "fill-translate": { + "base": 1, + "stops": [ + [ + 6, + [ + 2, + 0 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + } + } + }, + { + "id": "water", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "filter": [ + "all", + [ + "!=", + "intermittent", + 1 + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(210, 67%, 85%)" + } + }, + { + "id": "water-intermittent", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "filter": [ + "all", + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(210, 67%, 85%)", + "fill-opacity": 0.7 + } + }, + { + "id": "water-pattern", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "water", + "filter": [ + "all" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-pattern": "wave", + "fill-translate": [ + 0, + 2.5 + ] + } + }, + { + "id": "terrain-hillshade", + "type": "hillshade", + "paint": { + "hillshade-exaggeration": 1, + "hillshade-shadow-color": [ + "rgba(138, 138, 138, 1)" + ], + "hillshade-method": "igor", + "hillshade-illumination-anchor": "viewport", + "hillshade-highlight-color": [ + "rgba(255, 255, 255, 1)" + ] + }, + "layout": { + "visibility": "visible" + }, + "source": "terrain-source", + "maxzoom": 24 + }, + { + "id": "landcover-ice-shelf", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "subclass", + "ice_shelf" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#fff", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 0, + 0.9 + ], + [ + 10, + 0.3 + ] + ] + } + } + }, + { + "id": "landcover-sand", + "type": "fill", + "metadata": { + "mapbox:group": "1444849382550.77" + }, + "source": "dedicatedcode", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "sand" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(245, 238, 188, 1)", + "fill-opacity": 1 + } + }, + { + "id": "building", + "type": "fill", + "metadata": { + "mapbox:group": "1444849364238.8171" + }, + "source": "dedicatedcode", + "source-layer": "building", + "paint": { + "fill-antialias": true, + "fill-color": { + "base": 1, + "stops": [ + [ + 15.5, + "#f2eae2" + ], + [ + 16, + "#dfdbd7" + ] + ] + } + } + }, + { + "id": "building-top", + "type": "fill", + "metadata": { + "mapbox:group": "1444849364238.8171" + }, + "source": "dedicatedcode", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#f2eae2", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 16, + 1 + ] + ] + }, + "fill-outline-color": "#dfdbd7", + "fill-translate": { + "base": 1, + "stops": [ + [ + 14, + [ + 0, + 0 + ] + ], + [ + 16, + [ + -2, + -2 + ] + ] + ] + } + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "service", + "track" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 4 + ], + [ + 20, + 11 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(200, 147, 102, 1)", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "minor" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 17 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-trunk-primary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "tunnel-path-steps-casing", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 2 + ], + [ + 20, + 9.25 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-join": "bevel", + "line-cap": "butt" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 1.25 + ], + [ + 20, + 5.75 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "!=", + "subclass", + "steps" + ] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(244, 209, 158, 1)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "service", + "track" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15.5, + 0 + ], + [ + 16, + 2 + ], + [ + 20, + 7.5 + ] + ] + } + } + }, + { + "id": "tunnel-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "minor" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 10 + ] + ] + } + } + }, + { + "id": "tunnel-trunk-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ffdaa6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "tunnel-railway", + "type": "line", + "metadata": { + "mapbox:group": "1444849354174.1904" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "ferry", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "in", + "class", + "ferry" + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(108, 159, 182, 1)", + "line-dasharray": [ + 2, + 2 + ], + "line-width": 1.1 + } + }, + { + "id": "aeroway-taxiway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "in", + "class", + "taxiway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 2 + ], + [ + 17, + 12 + ] + ] + } + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 5 + ], + [ + 17, + 55 + ] + ] + } + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "in", + "class", + "runway", + "taxiway" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "class", + "taxiway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 11, + 0 + ], + [ + 12, + 1 + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 1 + ], + [ + 17, + 10 + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 11, + 0 + ], + [ + 12, + 1 + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 4 + ], + [ + 17, + 50 + ] + ] + } + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "pier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "#f8f4f0" + } + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "pier" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + } + }, + { + "id": "highway-area", + "type": "fill", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "!in", + "class", + "pier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": false, + "fill-color": "hsla(0, 0%, 89%, 0.56)", + "fill-opacity": 0.9, + "fill-outline-color": "#cfcdca" + } + }, + { + "id": "highway-path-steps-casing", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "in", + "subclass", + "steps" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 2 + ], + [ + 20, + 9.25 + ] + ] + } + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 17 + ] + ] + } + } + }, + { + "id": "highway-primary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 0.6 + ], + [ + 9, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 4, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [ + 4, + 0 + ], + [ + 5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 4, + 0 + ], + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-path", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "!=", + "subclass", + "steps" + ] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "highway-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-join": "bevel", + "line-cap": "butt" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 1.25 + ], + [ + 20, + 5.75 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 8, + 0.5 + ], + [ + 20, + 13 + ] + ] + } + } + }, + { + "id": "highway-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 8.5, + 0 + ], + [ + 9, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "highway-trunk", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "highway-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "railway-transit", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "railway-transit-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "railway-service", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ], + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "railway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!has", + "service" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "railway-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "!has", + "service" + ], + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 19 + ] + ] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 19 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 7, + 0.6 + ], + [ + 8, + 1.5 + ], + [ + 20, + 21 + ] + ] + } + } + }, + { + "id": "bridge-trunk-primary-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "hsl(28, 76%, 67%)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 26 + ] + ] + } + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 26 + ] + ] + } + } + }, + { + "id": "bridge-minor-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 6 + ], + [ + 20, + 24 + ] + ] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "path" + ] + ], + "paint": { + "line-color": "#cfcdca", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-path-steps", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "subclass", + "steps" + ] + ], + "layout": { + "line-join": "round", + "line-cap": "butt" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 1.25 + ], + [ + 20, + 5.75 + ] + ] + }, + "line-dasharray": [ + 0.5, + 0.25 + ] + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "path" + ], + [ + "!=", + "subclass", + "steps" + ] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "trunk", + "primary", + "secondary", + "tertiary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-minor", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 8, + 0.5 + ], + [ + 20, + 13 + ] + ] + } + } + }, + { + "id": "bridge-trunk-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "in", + "class", + "primary", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-railway", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "metadata": { + "mapbox:group": "1444849334699.1902" + }, + "source": "dedicatedcode", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + } + }, + { + "id": "cablecar", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "==", + "subclass", + "cable_car" + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-width": { + "base": 1, + "stops": [ + [ + 11, + 1 + ], + [ + 19, + 2.5 + ] + ] + } + } + }, + { + "id": "cablecar-dash", + "type": "line", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "==", + "subclass", + "cable_car" + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [ + 2, + 3 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 11, + 3 + ], + [ + 19, + 5.5 + ] + ] + } + } + }, + { + "id": "boundary-land-level-4", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "minzoom": 2, + "filter": [ + "all", + [ + ">=", + "admin_level", + 3 + ], + [ + "<=", + "admin_level", + 8 + ], + [ + "!=", + "maritime", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#9e9cab", + "line-dasharray": [ + 3, + 1, + 1, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 0.4 + ], + [ + 5, + 1 + ], + [ + 12, + 3 + ] + ] + } + } + }, + { + "id": "boundary-land-level-2", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "!=", + "maritime", + 1 + ], + [ + "!=", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 66%)", + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "boundary-land-disputed", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "filter": [ + "all", + [ + "!=", + "maritime", + 1 + ], + [ + "==", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 70%)", + "line-dasharray": [ + 1, + 3 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "boundary-water", + "type": "line", + "source": "dedicatedcode", + "source-layer": "boundary", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "admin_level", + 2, + 4 + ], + [ + "==", + "maritime", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(154, 189, 214, 1)", + "line-opacity": { + "stops": [ + [ + 6, + 0.6 + ], + [ + 10, + 1 + ] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "waterway", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "has", + "name" + ] + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-lakeline", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": [ + "==", + "$type", + "LineString" + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-ocean", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "ocean" + ] + ], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-other", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "water_name", + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "!in", + "class", + "ocean" + ] + ], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": { + "stops": [ + [ + 0, + 10 + ], + [ + 6, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "road_oneway", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + 1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "road_oneway_opposite", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + -1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + ">=", + "rank", + 25 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "<=", + "rank", + 24 + ], + [ + ">=", + "rank", + 15 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "<=", + "rank", + 14 + ], + [ + "has", + "name" + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "poi", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "has", + "name" + ], + [ + "==", + "class", + "railway" + ], + [ + "==", + "subclass", + "station" + ] + ], + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-image": "{class}_11", + "icon-optional": false, + "text-allow-overlap": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-ignore-placement": false, + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-optional": true, + "text-padding": 2, + "text-size": 12 + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 15.5, + "filter": [ + "==", + "class", + "path" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + } + }, + "paint": { + "text-color": "hsl(30, 23%, 62%)", + "text-halo-color": "#f8f4f0", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + } + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 12.2, + "filter": [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + } + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 8, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "network", + "us-interstate", + "us-highway", + "us-state" + ] + ], + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {} + }, + { + "id": "highway-shield-us-interstate", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 7, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "network", + "us-interstate" + ] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)" + } + }, + { + "id": "highway-shield-us-other", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "transportation_name", + "minzoom": 9, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "network", + "us-highway", + "us-state" + ] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)" + } + }, + { + "id": "airport-label-major", + "type": "symbol", + "source": "dedicatedcode", + "source-layer": "aerodrome_label", + "minzoom": 10, + "filter": [ + "all", + [ + "has", + "iata" + ] + ], + "layout": { + "icon-image": "airport_11", + "icon-size": 1, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 9, + "text-offset": [ + 0, + 0.6 + ], + "text-optional": true, + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "building-3d", + "type": "fill-extrusion", + "source": "dedicatedcode", + "source-layer": "building", + "minzoom": 1, + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-color": "rgba(209, 209, 209, 1)", + "fill-extrusion-height": { + "property": "render_height", + "type": "identity" + }, + "fill-extrusion-base": { + "property": "render_min_height", + "type": "identity" + }, + "fill-extrusion-opacity": 1, + "fill-extrusion-vertical-gradient": false, + "fill-extrusion-translate-anchor": "viewport" + } + }, + { + "id": "place-other", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "!in", + "class", + "city", + "town", + "village", + "state", + "country", + "continent" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 15, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "==", + "class", + "village" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 12 + ], + [ + 15, + 22 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "==", + "class", + "town" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 14 + ], + [ + 15, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "!=", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ] + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 7, + 14 + ], + [ + 11, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city-capital", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ] + ], + "layout": { + "icon-image": "star_11", + "icon-size": 0.8, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0.4, + 0 + ], + "text-size": { + "base": 1.2, + "stops": [ + [ + 7, + 14 + ], + [ + 11, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-state", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "in", + "class", + "state" + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 15, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-country-other", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 3 + ], + [ + "!has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 7, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 3 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 7, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "==", + "rank", + 2 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 2, + 11 + ], + [ + 5, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-1", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "==", + "rank", + 1 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 11 + ], + [ + 4, + 17 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-continent", + "type": "symbol", + "metadata": { + "mapbox:group": "1444849242106.713" + }, + "source": "dedicatedcode", + "source-layer": "place", + "maxzoom": 1, + "filter": [ + "==", + "class", + "continent" + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Bold" + ], + "text-max-width": 6.25, + "text-size": 14, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + } + ], + "id": "bright" +}' WHERE name = 'Reitti (Colored)'; \ No newline at end of file diff --git a/src/main/resources/db/migration/V104__add_default_device.sql b/src/main/resources/db/migration/V104__add_default_device.sql new file mode 100644 index 000000000..5d320a41b --- /dev/null +++ b/src/main/resources/db/migration/V104__add_default_device.sql @@ -0,0 +1,92 @@ +-- Step 1: Add the boolean column (original) +ALTER TABLE devices ADD COLUMN default_device BOOLEAN DEFAULT FALSE; + +-- Step 2: Create a default device for every user that lacks one +INSERT INTO devices (user_id, name, default_device,color, show_on_map, enabled, version) +SELECT id, 'Default', TRUE, '#f1ba63', TRUE, TRUE, 1 +FROM users +WHERE NOT EXISTS ( + SELECT 1 FROM devices d WHERE d.user_id = users.id AND d.default_device = TRUE +); +-- Step 3: Assign the default device to all raw_source_points with no device_id +UPDATE raw_source_points rsp +SET device_id = d.id +FROM devices d +WHERE d.user_id = rsp.user_id + AND d.default_device = TRUE + AND rsp.device_id IS NULL; + +-- Step 4: Attach every api_token without a device to the user's default device +UPDATE api_tokens at +SET device_id = d.id +FROM devices d +WHERE d.user_id = at.user_id + AND d.default_device = TRUE + AND at.device_id IS NULL; + +-- Step 5: update existing staging data +UPDATE staging_location_points SET device_id = d.id FROM devices d WHERE d.user_id = staging_location_points.user_id AND d.default_device = TRUE AND device_id IS NULL; +ALTER TABLE staging_location_points ALTER COLUMN device_id SET NOT NULL; + +-- Step 6: adjust raw_source_points +DROP VIEW IF EXISTS v_source_stream; + +CREATE OR REPLACE VIEW v_source_stream AS +WITH current_overrides AS ( + SELECT user_id, device_id, start_time, end_time + FROM timeline_overrides +) +-- PART A: Points from specific overridden devices +SELECT + rsp.id AS source_point_id, + rsp.accuracy_meters, + rsp.timestamp, + rsp.user_id, + rsp.geom, + rsp.elevation_meters, + rsp.device_id, + rsp.status +FROM raw_source_points rsp + JOIN current_overrides ov ON rsp.user_id = ov.user_id + AND rsp.device_id = ov.device_id + AND rsp.timestamp >= ov.start_time + AND rsp.timestamp < ov.end_time +WHERE rsp.status = 0 AND rsp.invalid IS FALSE + +UNION ALL + +-- Step B: Points from the user's "Main" (default) device where no override exists +SELECT + rsp.id AS source_point_id, + rsp.accuracy_meters, + rsp.timestamp, + rsp.user_id, + rsp.geom, + rsp.elevation_meters, + rsp.device_id, + rsp.status +FROM raw_source_points rsp + JOIN devices d ON d.id = rsp.device_id + AND d.user_id = rsp.user_id + AND d.default_device = TRUE +WHERE rsp.status = 0 + AND rsp.invalid IS FALSE + AND NOT EXISTS ( + SELECT 1 + FROM current_overrides ov + WHERE ov.user_id = rsp.user_id + AND rsp.timestamp >= ov.start_time + AND rsp.timestamp < ov.end_time +); + +-- Step 9: add device to mqtt integrations +ALTER TABLE mqtt_integrations ADD COLUMN device_id BIGINT; +UPDATE mqtt_integrations SET device_id = d.id FROM devices d WHERE d.user_id = mqtt_integrations.user_id AND d.default_device = TRUE AND device_id IS NULL; +ALTER TABLE mqtt_integrations ALTER COLUMN device_id SET NOT NULL; +ALTER TABLE mqtt_integrations ADD CONSTRAINT fk_device_id FOREIGN KEY (device_id) REFERENCES devices(id); + +-- Step 10: add device to owntracks recorder integrations +ALTER TABLE owntracks_recorder_integration ADD COLUMN reitti_device_id BIGINT; +UPDATE owntracks_recorder_integration SET reitti_device_id = d.id FROM devices d WHERE d.user_id = owntracks_recorder_integration.user_id AND d.default_device = TRUE AND reitti_device_id IS NULL; +ALTER TABLE owntracks_recorder_integration ALTER COLUMN reitti_device_id SET NOT NULL; +ALTER TABLE owntracks_recorder_integration ADD CONSTRAINT fk_device_id FOREIGN KEY (reitti_device_id) REFERENCES devices(id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V105__add_missing_api_keys_for_devices.sql b/src/main/resources/db/migration/V105__add_missing_api_keys_for_devices.sql new file mode 100644 index 000000000..6b7f9f656 --- /dev/null +++ b/src/main/resources/db/migration/V105__add_missing_api_keys_for_devices.sql @@ -0,0 +1,16 @@ +INSERT INTO api_tokens (token, user_id, name, created_at, version, device_id) +SELECT + gen_random_uuid()::text AS token, + d.user_id, + 'Auto-generated token for device ' || d.name AS name, + now() AS created_at, + 0 AS version, + d.id AS device_id +FROM devices d +WHERE NOT EXISTS ( + SELECT 1 + FROM api_tokens at + WHERE at.device_id = d.id +); + +create index api_token_usages_at_token_id_index on api_token_usages (at desc, token_id asc); \ No newline at end of file diff --git a/src/main/resources/db/migration/V106__unlink_reitti_styles_from_users.sql b/src/main/resources/db/migration/V106__unlink_reitti_styles_from_users.sql new file mode 100644 index 000000000..aed6fc1df --- /dev/null +++ b/src/main/resources/db/migration/V106__unlink_reitti_styles_from_users.sql @@ -0,0 +1,4 @@ +ALTER TABLE user_map_styles ALTER COLUMN user_id DROP NOT NULL; + +UPDATE user_map_styles SET user_id = NULL WHERE name = 'Reitti'; +UPDATE user_map_styles SET user_id = NULL WHERE name = 'Reitti (Colored)'; \ No newline at end of file diff --git a/src/main/resources/db/migration/V107__add_device_avatar_table.sql b/src/main/resources/db/migration/V107__add_device_avatar_table.sql new file mode 100644 index 000000000..3914803d6 --- /dev/null +++ b/src/main/resources/db/migration/V107__add_device_avatar_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE device_avatars +( + user_id BIGINT NOT NULL REFERENCES users (id), + device_id BIGINT NOT NULL REFERENCES devices (id), + binary_data bytea, + mime_type varchar(255) NOT NULL, + updated_at timestamp DEFAULT NOW() NOT NULL, + CONSTRAINT unique_key UNIQUE (user_id, device_id) +); diff --git a/src/main/resources/db/migration/V108__add_show_avatar_to_device_table.sql b/src/main/resources/db/migration/V108__add_show_avatar_to_device_table.sql new file mode 100644 index 000000000..521d509e5 --- /dev/null +++ b/src/main/resources/db/migration/V108__add_show_avatar_to_device_table.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN show_avatar_on_map BOOLEAN DEFAULT TRUE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V91__add_device_support_table.sql b/src/main/resources/db/migration/V91__add_device_support_table.sql new file mode 100644 index 000000000..d28e1768a --- /dev/null +++ b/src/main/resources/db/migration/V91__add_device_support_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE devices ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + color VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + show_on_map BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + version INTEGER NOT NULL DEFAULT 1 +); + +ALTER TABLE api_tokens ADD COLUMN device_id BIGINT REFERENCES devices(id) ON DELETE CASCADE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V92__add_geo_point_staging_table.sql b/src/main/resources/db/migration/V92__add_geo_point_staging_table.sql new file mode 100644 index 000000000..abb62ae2a --- /dev/null +++ b/src/main/resources/db/migration/V92__add_geo_point_staging_table.sql @@ -0,0 +1,31 @@ +-- 1. Create the Parent Table +CREATE TABLE staging_location_points +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + partition_key TEXT NOT NULL, + user_id BIGINT REFERENCES users (id), -- Who uploaded it + device_id BIGINT REFERENCES devices (id), + timestamp TIMESTAMP(6) WITH TIME ZONE NOT NULL, + geom geometry(Point, 4326), + elevation_meters DOUBLE PRECISION, + accuracy_meters DOUBLE PRECISION NOT NULL, + promoted BOOLEAN DEFAULT FALSE, + PRIMARY KEY (id, partition_key) +) PARTITION BY LIST (partition_key); + +CREATE INDEX staging_location_geom_idx ON staging_location_points USING GIST (geom); + +CREATE TABLE import_jobs +( + id UUID PRIMARY KEY, -- The Job ID we generate + user_id BIGINT REFERENCES users (id), -- Who uploaded it + type VARCHAR(30), -- 'GPX_IMPORT', 'API_BATCH', 'MANUAL_ENTRY' + status VARCHAR(20) NOT NULL, -- PARSING, AWAITING_PROMOTION, COMPLETED, FAILED + file_name VARCHAR(255), -- Original GPX name for the UI + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_import_jobs_status_age ON import_jobs (status, updated_at); + +ALTER TABLE users ADD COLUMN last_data_modified_at TIMESTAMP WITH TIME ZONE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V93__add_new_job_status_columns.sql b/src/main/resources/db/migration/V93__add_new_job_status_columns.sql new file mode 100644 index 000000000..496b9a458 --- /dev/null +++ b/src/main/resources/db/migration/V93__add_new_job_status_columns.sql @@ -0,0 +1,36 @@ +ALTER TABLE import_jobs ADD COLUMN IF NOT EXISTS friendly_name VARCHAR(255); +ALTER TABLE import_jobs ADD COLUMN IF NOT EXISTS enqueued_at TIMESTAMP; +ALTER TABLE import_jobs ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMP; +ALTER TABLE import_jobs ADD COLUMN IF NOT EXISTS processing_at TIMESTAMP; +ALTER TABLE import_jobs ADD COLUMN IF NOT EXISTS finished_at TIMESTAMP; + +-- Optional: Add index on the status column for faster queries +CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); + +ALTER TABLE import_jobs + ADD COLUMN max_progress BIGINT; +ALTER TABLE import_jobs + ADD COLUMN current_progress BIGINT; +ALTER TABLE import_jobs + ADD COLUMN progress_message TEXT; + +create table scheduled_tasks +( + task_name text not null, + task_instance text not null, + task_data bytea, + execution_time timestamp with time zone not null, + picked BOOLEAN not null, + picked_by text, + last_success timestamp with time zone, + last_failure timestamp with time zone, + consecutive_failures INT, + last_heartbeat timestamp with time zone, + version BIGINT not null, + priority SMALLINT, + PRIMARY KEY (task_name, task_instance) +); + +CREATE INDEX execution_time_idx ON scheduled_tasks (execution_time); +CREATE INDEX last_heartbeat_idx ON scheduled_tasks (last_heartbeat); +CREATE INDEX priority_execution_time_idx on scheduled_tasks (priority desc, execution_time asc); diff --git a/src/main/resources/db/migration/V94__add_parent_job_ids.sql b/src/main/resources/db/migration/V94__add_parent_job_ids.sql new file mode 100644 index 000000000..0f53f5168 --- /dev/null +++ b/src/main/resources/db/migration/V94__add_parent_job_ids.sql @@ -0,0 +1 @@ +ALTER TABLE import_jobs ADD COLUMN parent_job_id UUID REFERENCES import_jobs(id) ON DELETE CASCADE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V95__add_source_points_table.sql b/src/main/resources/db/migration/V95__add_source_points_table.sql new file mode 100644 index 000000000..11ed1b6cc --- /dev/null +++ b/src/main/resources/db/migration/V95__add_source_points_table.sql @@ -0,0 +1,158 @@ +CREATE TABLE raw_source_points AS +SELECT accuracy_meters, + timestamp, + user_id, + geom, + elevation_meters, + NULL::bigint AS device_id, + ignored, + invalid +FROM raw_location_points +WHERE synthetic = FALSE; + +-- 2. Add Identity and Constraints +ALTER TABLE raw_source_points + ADD COLUMN id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY; + +CREATE UNIQUE INDEX idx_raw_source_upsert_target + ON raw_source_points (user_id, timestamp, device_id) + NULLS NOT DISTINCT; + +-- 3. Add the Partial Indices (The secret to performance) +CREATE INDEX idx_raw_source_main_device + ON raw_source_points (user_id, timestamp) + WHERE device_id IS NULL; + +CREATE INDEX idx_raw_source_external_devices + ON raw_source_points (device_id, timestamp) + WHERE device_id IS NOT NULL; + + +CREATE TABLE raw_location_points_new AS +SELECT rlp.id, + rlp.accuracy_meters, + rlp.timestamp, + rlp.user_id, + rlp.geom, + rlp.processed, + rlp.version, + rlp.elevation_meters, + rlp.synthetic, + rlp.ignored, + rlp.invalid, + CASE + WHEN rlp.synthetic = FALSE THEN rsp.id + ELSE NULL + END AS source_point_id +FROM raw_location_points rlp + LEFT JOIN raw_source_points rsp + ON rlp.user_id = rsp.user_id + AND rlp.timestamp = rsp.timestamp; +-- 2. Drop the slow table and rename the new one +DROP TABLE raw_location_points CASCADE; +ALTER TABLE raw_location_points_new + RENAME TO raw_location_points; + +-- 1. Explicitly set the column to NOT NULL +ALTER TABLE raw_location_points + ALTER COLUMN id SET NOT NULL; + +-- 2. Now add the identity property +ALTER TABLE raw_location_points + ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY; + +-- 3. Sync the sequence so the next ID starts after your current max +SELECT setval( + pg_get_serial_sequence('raw_location_points', 'id'), + (SELECT MAX(id) FROM raw_location_points) + ); + +-- 3. Re-apply the DDL (Primary Key, Indices, Owner) +ALTER TABLE raw_location_points + ADD PRIMARY KEY (id); +ALTER TABLE public.raw_location_points + ALTER COLUMN invalid SET DEFAULT false, + ALTER COLUMN invalid SET NOT NULL, + ALTER COLUMN synthetic SET DEFAULT false, + ALTER COLUMN synthetic SET NOT NULL, + ALTER COLUMN ignored SET DEFAULT false, + ALTER COLUMN ignored SET NOT NULL; + +CREATE INDEX idx_raw_location_points_user_time_synthetic + ON raw_location_points (user_id, timestamp, synthetic); + +CREATE INDEX raw_location_points_processed + ON raw_location_points (processed); + +CREATE UNIQUE INDEX raw_location_points_user_id_timestamp_uindex + ON raw_location_points (user_id, timestamp); + +CREATE INDEX idx_covering_user_time + ON raw_location_points (user_id, timestamp) INCLUDE (geom); + +CREATE INDEX idx_raw_locations_user_timestamp_valid + ON raw_location_points (user_id, invalid, ignored, timestamp) + WHERE ((invalid = FALSE) AND (ignored = FALSE)); + +CREATE INDEX raw_location_points_user_id_processed_invalid_index + ON raw_location_points (user_id, processed, invalid) INCLUDE (id, timestamp); + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- add time line override table +CREATE TABLE timeline_overrides +( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users (id), + device_id bigint NOT NULL REFERENCES devices (id), + start_time timestamp(6) with time zone NOT NULL, + end_time timestamp(6) with time zone NOT NULL, + created_at timestamp with time zone DEFAULT NOW(), + CONSTRAINT no_timeline_overlap EXCLUDE USING gist ( + user_id WITH =, + TSTZRANGE(start_time, end_time) WITH && + ) +); + + +CREATE OR REPLACE VIEW v_source_stream AS +WITH current_overrides AS ( + SELECT user_id, device_id, start_time, end_time + FROM timeline_overrides +) +-- PART A: Points from specific overridden devices +SELECT + rsp.id AS source_point_id, -- <--- Crucial back-link + rsp.accuracy_meters, + rsp.timestamp, + rsp.user_id, + rsp.geom, + rsp.elevation_meters, + rsp.device_id +FROM raw_source_points rsp + JOIN current_overrides ov ON rsp.user_id = ov.user_id + AND rsp.device_id = ov.device_id + AND rsp.timestamp >= ov.start_time + AND rsp.timestamp <= ov.end_time + +UNION ALL + +-- PART B: Points from the "Main" device (where no override exists) +SELECT + rsp.id AS source_point_id, -- <--- Crucial back-link + rsp.accuracy_meters, + rsp.timestamp, + rsp.user_id, + rsp.geom, + rsp.elevation_meters, + rsp.device_id +FROM raw_source_points rsp +WHERE rsp.device_id IS NULL -- This is our "Main" device convention + AND NOT EXISTS ( + -- Only take the main point if there is NO override for this specific second + SELECT 1 + FROM current_overrides ov + WHERE ov.user_id = rsp.user_id + AND rsp.timestamp >= ov.start_time + AND rsp.timestamp <= ov.end_time +); diff --git a/src/main/resources/db/migration/V96__add_task_name_to_job_meta_data.sql b/src/main/resources/db/migration/V96__add_task_name_to_job_meta_data.sql new file mode 100644 index 000000000..918e7202a --- /dev/null +++ b/src/main/resources/db/migration/V96__add_task_name_to_job_meta_data.sql @@ -0,0 +1,2 @@ +ALTER TABLE import_jobs ADD COLUMN task_id VARCHAR(255); +ALTER TABLE import_jobs RENAME TO job_meta_data; diff --git a/src/main/resources/db/migration/V97__update_location_point_status.sql b/src/main/resources/db/migration/V97__update_location_point_status.sql new file mode 100644 index 000000000..704a64d78 --- /dev/null +++ b/src/main/resources/db/migration/V97__update_location_point_status.sql @@ -0,0 +1,54 @@ +ALTER TABLE raw_source_points ADD COLUMN status INT DEFAULT 0; +UPDATE raw_source_points SET status = 1 WHERE ignored; + +CREATE OR REPLACE VIEW v_source_stream AS +WITH current_overrides AS ( + SELECT user_id, device_id, start_time, end_time + FROM timeline_overrides +) +-- PART A: Points from specific overridden devices +SELECT + rsp.id AS source_point_id, + rsp.accuracy_meters, + rsp.timestamp, + rsp.user_id, + rsp.geom, + rsp.elevation_meters, + rsp.device_id, + rsp.status -- Pass this through +FROM raw_source_points rsp + JOIN current_overrides ov ON rsp.user_id = ov.user_id + AND rsp.device_id = ov.device_id + AND rsp.timestamp >= ov.start_time + AND rsp.timestamp < ov.end_time +WHERE rsp.status = 0 AND rsp.invalid IS FALSE + +UNION ALL + +-- PART B: Points from the "Main" device +SELECT + rsp.id AS source_point_id, + rsp.accuracy_meters, + rsp.timestamp, + rsp.user_id, + rsp.geom, + rsp.elevation_meters, + rsp.device_id, + rsp.status +FROM raw_source_points rsp +WHERE rsp.device_id IS NULL + AND rsp.status = 0 + AND rsp.invalid IS FALSE + AND NOT EXISTS ( + SELECT 1 + FROM current_overrides ov + WHERE ov.user_id = rsp.user_id + AND rsp.timestamp >= ov.start_time + AND rsp.timestamp < ov.end_time +); + +ALTER TABLE raw_source_points DROP COLUMN ignored; +ALTER TABLE raw_location_points DROP COLUMN ignored; +ALTER TABLE raw_location_points DROP COLUMN invalid; + +ALTER TABLE preview_raw_location_points DROP COLUMN ignored; \ No newline at end of file diff --git a/src/main/resources/db/migration/V98__update_location_daily_sumary.sql b/src/main/resources/db/migration/V98__update_location_daily_sumary.sql new file mode 100644 index 000000000..f6b461e83 --- /dev/null +++ b/src/main/resources/db/migration/V98__update_location_daily_sumary.sql @@ -0,0 +1,14 @@ +DROP MATERIALIZED VIEW IF EXISTS location_daily_sumary; + +CREATE MATERIALIZED VIEW location_daily_summary AS +SELECT + user_id, + timestamp::date AS day, + COUNT(*) AS point_count, + MIN(timestamp) AS min_ts, + MAX(timestamp) AS max_ts, + ST_Extent(geom) AS bbox +FROM raw_location_points +GROUP BY user_id, timestamp::date; + +CREATE UNIQUE INDEX ON location_daily_summary (user_id, day); diff --git a/src/main/resources/db/migration/V99__add_metadata_tables.sql b/src/main/resources/db/migration/V99__add_metadata_tables.sql new file mode 100644 index 000000000..da9d259b8 --- /dev/null +++ b/src/main/resources/db/migration/V99__add_metadata_tables.sql @@ -0,0 +1,21 @@ +CREATE TABLE location_metadata +( + id SERIAL PRIMARY KEY, + user_id BIGINT REFERENCES users (id) NOT NULL, + -- Acts purely as a hint/context for the UI or recalculation filter + context_type VARCHAR(10) NOT NULL, -- 'TRIP' or 'VISIT' + -- The absolute temporal anchor + time_range TSTZRANGE NOT NULL, + -- The user data payload + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_location_metadata_user_time ON location_metadata (user_id, time_range); +CREATE INDEX idx_metadata_tags_gin ON location_metadata USING gin ((metadata->'tags')); +CREATE INDEX idx_metadata_companions_gin ON location_metadata USING gin ((metadata->'companions')); + +ALTER TABLE processed_visits + ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE trips + ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb; \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 8b3abc432..acab6eab5 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -5,6 +5,7 @@ edit-place.page.title=Edit Place - Reitti # Navigation nav.timeline=Timeline nav.statistics=Statistics +nav.workbench=Workbench nav.memories=Memories nav.settings=Settings nav.logout=Logout @@ -26,7 +27,16 @@ timeline.duration=Duration timeline.distance=Distance timeline.trip=Trip timeline.visit=Visit +timeline.user.follow=Focus on this user +timeline.devices.toggle.title=Open menu ... +timeline.devices.divider=Devices timeline.trip.transport.select=Select a transport mode + +# Timeline - Grouped Headline +timeline.grouped.headline.weekly=Week {0} +timeline.grouped.activity-overview.headline=Activity Overview +timeline.grouped.amount-overview.places=Visited Places +timeline.grouped.amount-overview.trips=Journeys js.timeline.state.hide.title=Hide Timeline js.timeline.state.show.title=Show Timeline transportation.mode.WALKING.name=Walking @@ -95,6 +105,7 @@ settings.places=Places settings.transportation-modes=Transportation Modes settings.geocoding=Geocoding settings.integrations=Integrations +settings.devices=Devices settings.logging=Logging settings.manage.data=Manage Data settings.job.status=Job Status @@ -369,11 +380,16 @@ tokens.table.token=Token tokens.table.created=Created tokens.table.last.used=Last Used tokens.table.actions=Actions -tokens.no.tokens=No API tokens found. Create one to get started. +tokens.no.tokens=No API tokens were found. Create one to get started. +tokens.link.to.device=View Device +tokens.link.device.attach=Attach to device +tokens.link.device.detach=Detach from {0} tokens.delete.confirm=Are you sure you want to delete this token? +tokens.detach.confirm=Are you sure you want to detach this token from {0}? You will no longer be able to use this token to ingest data over the API. tokens.recent.usages.title=Recent Token Usages tokens.recent.usages.description=Showing the last {0} token usages tokens.usage.table.token=Token Name +tokens.usage.table.device=Device tokens.usage.table.timestamp=Timestamp tokens.usage.table.endpoint=Endpoint tokens.usage.table.ip=IP Address @@ -457,8 +473,6 @@ js.users.custom.css.remove.confirm=Are you sure you want to remove the current c users.custom.css.error.to-large=CSS file too large. The maximum size is 1MB. users.custom.css.error.invalid-file-type=Invalid file type. Only CSS files are allowed. users.custom.css.error.generic=Error processing CSS file: {0} -map.colored.preference=Show map in color -map.colored.preference.description=When enabled, the map will be displayed in full color. When disabled, the map will be shown in grayscale. # Units units.title=Unit System units.metric=Metric @@ -561,11 +575,14 @@ form.refresh=Refresh # Messages message.success.token.created=Token created successfully +message.success.token.detached=Token successfully detached from {0} +message.success.token.attach=Token successfully attached to {0} message.success.token.deleted=Token deleted successfully message.success.user.created=User created successfully message.success.user.updated=User updated successfully message.success.user.deleted=User deleted successfully message.success.place.updated=Place updated successfully +message.error.generic=An error occurred: {0} message.error.token.creation=Error creating token: {0} message.error.token.deletion=Error deleting token: {0} message.error.user.creation=Error creating user: {0} @@ -605,8 +622,12 @@ upload.button.geojson=Upload GeoJSON File upload.no.files=No files selected upload.file.empty=File is empty upload.invalid.format=Invalid file format +upload.select.device = Choose a device upload.success=Successfully processed {0} file(s) with {1} location points upload.error=No files were processed successfully +upload.error.max_upload_size_exceeded=Maximum upload limits were exceeded. Please select not more than {0} files, with a total size of no more than {1}. +upload.error.file.empty=File {0} is empty +upload.error.file.wrong_extension=File {0} has an invalid extension. Only {1} files are supported. # Integrations integrations.title=Integrations @@ -652,6 +673,20 @@ integrations.overland.configure.description=This will configure Overland to repo integrations.owntracks.configure=Autoconfigure Owntracks integrations.owntracks.configure.description=This will configure Owntracks to report location data to Reitti. +#Colota Integration + +integrations.colota.title=Colota Setup +integrations.colota.description=Colota is a self-hosted GPS tracking app for Android. It sends your location to your own server over HTTP(S), works offline, supports geofencing, and has no analytics or telemetry. +integrations.colota.step1=Install the Colota app on your Android device. +integrations.colota.step2=Open the app and got to "Settings" > "API Settings". +integrations.colota.step3=Select the Reitti template +integrations.colota.step4.with.token=Set the Endpoint URL to: {0} +integrations.colota.step4.without.token=Set the Endpoint URL to: {0} +integrations.colota.step5=Configure Authentication to Custom and add the following values: +integrations.colota.step6=Configure your tracking settings in Colota to your liking and start tracking! +integrations.colota.configure=Configure Colota +integrations.colota.configure.description=This will configure Colota to report location data to Reitti. + # OwnTracks Recorder Integration integrations.owntracks.recorder.title=OwnTracks Recorder Integration integrations.owntracks.recorder.description=Connect to an OwnTracks Recorder instance to fetch location data from specific users and devices. @@ -664,6 +699,8 @@ integrations.owntracks.recorder.device.id.placeholder=Enter the device ID to fet integrations.owntracks.recorder.auth.username=Authentication Username integrations.owntracks.recorder.auth.username.placeholder=Enter username for basic auth (optional) integrations.owntracks.recorder.auth.password=Authentication Password +integrations.owntracks.recorder.reittiDevice=Reitti Device +integrations.owntracks.recorder.reittiDevice.description=Select the device that incoming location data should be linked to integrations.owntracks.recorder.auth.password.placeholder=Enter password for basic auth (optional) integrations.owntracks.recorder.auth.optional=Leave empty if no authentication is needed integrations.owntracks.recorder.enabled=Enable Integration @@ -706,12 +743,15 @@ integrations.mqtt.payload.type=Payload Type integrations.mqtt.payload.type.owntracks=OwnTracks integrations.mqtt.payload.type.help=Select the format of the location data payload integrations.mqtt.enabled=Enable Integration +integrations.mqtt.reittiDevice=Reitti Device +integrations.mqtt.reittiDevice.description=Select the device that incoming location data should be linked to integrations.mqtt.save=Save Configuration integrations.mqtt.test.connection=Test Connection js.integrations.mqtt.test.missing.fields=Please fill in Host, Port and Topic js.integrations.mqtt.test.loading=Testing connection... js.integrations.mqtt.test.failed=Connection test failed +integration.mqtt.error.unknown_device=The given device is not registered with Reitti. Please register the device first. integration.mqtt.error.port_range=Port must be between 1 and 65,535 integration.mqtt.error.out_of_date=The integration has been modified by another session. Please refresh and try again. integration.mqtt.error.saving=Failed to save integration: {0} @@ -834,6 +874,19 @@ form.close=Close jobs.title=Job Status jobs.refresh=Refresh Status jobs.estimated.time=Est. processing time: {0} +jobs.pending.title=Pending & Running Jobs +jobs.pending.empty=Everything is up to date! +jobs.name=Name +jobs.description=Description +jobs.state=State +jobs.enqueuedAt=Enqueued At: +jobs.scheduledAt=Scheduled At: +jobs.finishedAt=Finished At: +jobs.estimated=Estimated Time: +jobs.actions=Actions +jobs.childJobs=Child Jobs +jobs.past.title=Finished Jobs +jobs.past.empty=No finished jobs yet # Queue Descriptions queue.location.data.name=Location Data Processing @@ -966,7 +1019,10 @@ language.russian=Russian language.japanese=Japanese language.brazilian_portuguese=Portuguese (Brazil) language.polish=Polish +language.portuguese=Portuguese (Portugal) +language.korean=Korean language.chinese=Chinese +language.chinese_traditional=Chinese (Traditional Han script) language.dutch=Dutch language.turkish=Turkish language.ukrainian=Ukrainian @@ -1039,9 +1095,9 @@ priority.5.label=Lowest js.sse.error.connection-lost=Connection to server lost! Try reconnecting \u2026 # Map js.map.auto-update.latest-location=Latest location -js.map.auto-update.enable.title=Enter Auto-Update Mode -js.map.auto-update.disable.title=Leave Auto-Update Mode -js.map.fullscreen.toggle.title=Toggle Fullscreen +map.auto-update.enable.title=Enter Auto-Update Mode +map.auto-update.disable.title=Leave Auto-Update Mode +map.fullscreen.toggle.title=Toggle Fullscreen js.map.popup.labels.total_duration=Total Duration: js.map.popup.labels.from=From @@ -1053,9 +1109,11 @@ map.time-control.speed.normal=Normal (1x) map.time-control.speed.fast=Fast (60x) map.time-control.speed.super_fast=Superfast (3600x) map.time-control.speed.auto=Auto-Adjust +map.time-control.speed.adaptive=Adaptive js.map.display-control.title=Map Controls js.map.display-control.mode.3d.enabled.text=Disable 3D +js.map.display-control.map-style.title=Change Map Style js.map.display-control.mode.3d.enabled.title=Switch to 2D View js.map.display-control.mode.3d.disabled.text=Enable 3D js.map.display-control.mode.3d.disabled.title=Switch to 3D View @@ -1090,12 +1148,92 @@ js.map.settings.dialog.appearance.view-mode.24h_aggregate=24h aggregate? js.map.settings.dialog.interface.title=Interface js.map.settings.dialog.interface.timeline-visible=Timeline Visible js.map.settings.dialog.interface.datepicker-visible=Date Selection Visible +js.map.settings.dialog.interface.show-avatars=Show Avatars? + +settings.map.styles=Map Styles +settings.map.styles.description=Manage custom MapLibre styles and optional tile sources +map.settings.dialog.map-styles.table.name=Name +map.settings.dialog.map-styles.table.type=Type +map.settings.dialog.map-styles.table.notes=Note +map.settings.dialog.map-styles.table.default=Managed by Reitti +map.settings.dialog.map-styles.table.shared=Shared with you +map.settings.dialog.map-styles.table.active=Active +map.settings.dialog.map-styles.table.actions=Actions +map.settings.dialog.map-styles.table.status.active=In use +map.settings.dialog.map-styles.table.status.inactive=Available + +map.settings.dialog.map-styles.set-as-active=Set as active style +map.settings.dialog.map-styles.active=Active Style +map.settings.dialog.map-styles.custom-title=Custom Styles +map.settings.dialog.map-styles.add=Add Style +map.settings.dialog.map-styles.add-title=Add Custom Style +map.settings.dialog.map-styles.name=Name +map.settings.dialog.map-styles.style-json=Style JSON +map.settings.dialog.map-styles.style-json-url=Style JSON URL +map.settings.dialog.map-styles.map-type=Map Type +map.settings.dialog.map-styles.map-type.vector=Vector Style +map.settings.dialog.map-styles.map-type.raster=Raster Tiles +map.settings.dialog.map-styles.vector-title=Vector Style +map.settings.dialog.map-styles.raster-title=Raster Tiles +map.settings.dialog.map-styles.style-input=Style Input +map.settings.dialog.map-styles.style-input.url=Style JSON URL +map.settings.dialog.map-styles.style-input.json=Paste Style JSON +map.settings.dialog.map-styles.source-input=Source Input +map.settings.dialog.map-styles.source-input.tile-template=Tile URL Template +map.settings.dialog.map-styles.source-input.tilejson=TileJSON URL +map.settings.dialog.map-styles.advanced-options=Advanced Options +map.settings.dialog.map-styles.attribution-override=Attribution Override +map.settings.dialog.map-styles.glyphs-url=Glyphs URL Override +map.settings.dialog.map-styles.sprite-url=Sprite URL Override +map.settings.dialog.map-styles.tile-settings=Tile Settings +map.settings.dialog.map-styles.tile-size=Tile Size +map.settings.dialog.map-styles.scheme=Scheme +map.settings.dialog.map-styles.tilejson-url=TileJSON URL +map.settings.dialog.map-styles.tile-template=Tile URL Template +map.settings.dialog.map-styles.attribution=Attribution +map.settings.dialog.map-styles.minzoom=Min. Tile Zoom +map.settings.dialog.map-styles.maxzoom=Max. Tile Zoom +map.settings.dialog.map-styles.save=Save +map.settings.dialog.map-styles.cancel=Cancel +map.settings.dialog.map-styles.shared=Share with all users +map.settings.dialog.map-styles.shared-info=Other users can select this style, but only you can edit it. +map.settings.dialog.map-styles.shared-badge=Shared +map.settings.dialog.map-styles.proxy-tiles=Proxy tiles through Reitti +map.settings.dialog.map-styles.proxy-tiles-info=Tile requests for this style are fetched through Reitti. +map.settings.dialog.map-styles.proxy-badge=Proxied +map.settings.dialog.map-styles.error-url-required={0} is required. +map.settings.dialog.map-styles.error-url-invalid={0} must be a valid URL. +map.settings.dialog.map-styles.error-url-scheme={0} must use HTTP or HTTPS. +map.settings.dialog.map-styles.error-url-host={0} must include a host. +map.settings.dialog.map-styles.error-url-credentials={0} must not contain embedded credentials. +map.settings.dialog.map-styles.error-name-required=Name is required. +map.settings.dialog.map-styles.error-style-json-required=Style JSON is required. +map.settings.dialog.map-styles.error-style-url-required=Style JSON URL is required. +map.settings.dialog.map-styles.error-tile-template-placeholders=Tile URL Template must contain {z}, {x}, and {y}. +map.settings.dialog.map-styles.error-tilejson-required=TileJSON URL is required. +map.settings.dialog.map-styles.error-zoom-order=Min Zoom must be lower than Max Zoom. +map.settings.dialog.map-styles.error-zoom-range={0} must be between 0 and 24. +map.settings.dialog.map-styles.error-json=The pasted style JSON is invalid. +map.settings.dialog.map-styles.name.placeholder=My custom style +map.settings.dialog.map-styles.edit-title=Edit Custom Style +map.settings.dialog.map-styles.edit=Edit +map.settings.dialog.map-styles.remove=Remove +map.settings.dialog.map-styles.empty=No custom styles yet +map.settings.dialog.map-styles.remove-confirm=Remove this custom map style? + + +map.settings.dialog.map-styles.error-save=Could not save this custom style. +map.settings.dialog.map-styles.error-save-status=Could not save this custom style (HTTP {0}). +map.settings.dialog.map-styles.error-delete=Could not delete this custom style. +map.settings.dialog.map-styles.error-active=Could not save the active map style. +map.settings.dialog.map-styles.delete.confirm=Are you sure you want to delete this map style? map.settings.dialog.date-picker.title=Date Selection # Export Data export.title=Export Data export.date.range=Date Range +export.device.label=Device export.start.date=Start Date export.end.date=End Date export.gpx.button=Export as GPX @@ -1105,8 +1243,7 @@ export.raw.data.table.timestamp=Timestamp export.raw.data.table.latitude=Latitude export.raw.data.table.longitude=Longitude export.raw.data.table.accuracy=Accuracy (m) -export.raw.data.table.processed=Processed -export.raw.data.no.data=No location data found for the selected date range +export.raw.data.no.data=No location data was found for the selected date range export.raw.data.loading=Loading location data... export.raw.data.showing=Showing {0} - {1} of {2} export.raw.data.show=Show: @@ -1114,7 +1251,7 @@ export.raw.data.previous=Previous export.raw.data.next=Next export.raw.data.page.info=Page {0} of {1} export.gpx.success=GPX file exported successfully -export.gpx.error=Error exporting GPX file: {0} +export.gpx.error=Error exporting the GPX file: {0} # Generic Labels @@ -1343,7 +1480,7 @@ visit.sensitivity.preview.new=Preview Data visit.sensitivity.preview.date=Preview Date: visit.sensitivity.preview.calculating=Calculating\u2026 js.visit.sensitivity.preview.ready=Ready -js.visit.sensitivity.preview.error=Ready +js.visit.sensitivity.preview.error=Error visit.sensitivity.preview.config.details=Configuration Details visit.sensitivity.visit.detection=Visit Detection visit.sensitivity.search.distance=Search Distance @@ -1366,6 +1503,7 @@ settings.transportation-modes.description=View and manage your settings for tran settings.geocoding.description=Configure geocoding services to convert coordinates to addresses settings.manage.data.description=Manually trigger data processing and manage your location data settings.integrations.description=Connect external services and mobile apps to automatically import location data +settings.devices.description=Log multiple devices and view their location history or merge them into your timeline settings.about.description=View application version and build information settings.logging.description=Configure logging levels and view logs memory.new.page.title=New Memory - Reitti @@ -1617,6 +1755,152 @@ common.distance.mi={0,number,#.0} mi common.distance.ft={0,number,#} ft common.actions.apply=Apply +common.actions.save=Save +common.choice.yes=Yes +common.choice.no=No + +common.choice.enable=Enable +common.choice.disable=Disable + +common.form.date=Date: js.autoupdate.state.disable=Leave Auto-Update-Mode -js.autoupdate.state.enable=Enter Auto-Update-Mode \ No newline at end of file +js.autoupdate.state.enable=Enter Auto-Update-Mode + + +devices.title=Devices +devices.table.name=Name +devices.table.status=Status +devices.table.showOnMap=Show on Map +devices.table.created=Created +devices.table.actions=Actions +devices.name.label=Device Name +devices.name.placeholder=Enter device name +devices.color.label=Color +devices.enabled.label=Enabled? +devices.showOnMap.label=Show path on Map? +devices.showAvatar.label=Show Avatar on Map? +devices.delete.confirm=Are you sure you want to delete this device? +devices.default.confirm=Are you sure you want to set this device as the default? +devices.status.default=Your default device +devices.status.enabled=Enabled +devices.status.disabled=Disabled +devices.add.title=Add Device +devices.edit.title=Edit Device +devices.actions.set-default=Set as Default +devices.color.theme.label=Color Theme +devices.color.theme.description=Choose your preferred accent color for the map for this device. +devices.color.theme.reset=Reset to Default +devices.color.theme.custom=Custom Color +devices.color.theme.custom.input=Custom Color: +devices.settings.title=Settings +devices.enabled.description=Enable or disable this device. Disabled devices will not show up in the ui and can’t be used to track locations. +devices.show-on-map.description=When enabled, this device will show up on the main map and in the user/device list on the left. +devices.show-avatar-on-map.description=When enabled, this device will show up as a small avatar on the map. + +devices.avatar.label=Device Picture +devices.avatar.error.generic=Error processing the image file: {0} + +message.success.device.created=Device created successfully +message.error.device.creation=Error creating device: {0} +message.success.device.updated=Device update successfully +message.error.device.update=Error updating the device: {0} +message.success.device.deleted=Device deleted successfully +message.error.device.deletion=Error deleting a device: {0} +message.error.device.deletion.default=Your default device cannot be deleted. Please create a new device before deleting this one or switch to a different default device. +message.success.device.toggled=Device toggled successfully +message.success.device.default-device=Default device set to {0} successfully. Verify your integration settings to ensure the new default device is used. +message.error.device.toggle=Error toggling device: {0} +message.error.device.not.found=Device not found +workbench.device.default.label=Default +workbench.action.weave.title=Weave this patch into the Main Journey +workbench.action.inspect=Inspect +workbench.action.select=Select +workbench.action.boxselect=Box +workbench.action.help.title=Keyboard Shortcuts (?) +workbench.help.previous_next_point=Previous / next point +workbench.help.extend_selection=Extend selection by one point +workbench.help.jump_to_first_last_point=Jump to the first / last point in the window +workbench.help.extend_to_start_end=Extend selection to start / end +workbench.help.select_all_in_window=Select all points in the editable window +workbench.help.clear_selection=Clear selection +workbench.help.headline.selection=Selection +workbench.help.headline.editing=Editing +workbench.help.remove_selected_point=Remove selected points +workbench.help.footnote=On macOS, Cmd works in place of Ctrl. +workbench.help.headline=Keyboard shortcuts +workbench.history.action.clear=Clear +workbench.history.headline=Action History +workbench.history.empty=No actions yet.
copy a patch or move a point
to begin weaving. +js.workbench.history.empty=No actions yet.
copy a patch or move a point
to begin weaving. +js.workbench.history.clear=Revert all {0} actions and clear the log? +workbench.loading.label=[Loading\u2026] +js.workbench.toast.selected_points=Selected {0} vertices \u00B7 drag any to move them all +js.workbench.toast.deleted_points=Removed {0,choice,1#1 point|1<{0,number,integer} points} +js.workbench.toast.deleted_points.none=Nothing to delete (no movable points selected) +js.workbench.toast.patch.no_points=No points in the selected patch range +js.workbench.toast.patch.points=Wove {0} {1} points into the story +js.workbench.toast.reverted=Reverted: {0} +js.workbench.action.move.single.short_description=move @ {0} +js.workbench.action.move.single.description=Nudged vertex at {0} by {1} m\u00B7 linked to {2} +js.workbench.action.move.multi.short_description=move {0,choice,1#1 point|1<{0,number,integer} points} +js.workbench.action.move.multi.description=Nudged {0,choice,1#1 point|1<{0,number,integer} points} by {1} m \u00B7 group +js.workbench.action.delete.short_description={0,choice,1#1 point|1<{0,number,integer} points} deleted +js.workbench.action.delete.description=Removed {0,choice,1#1 point|1<{0,number,integer} points} between {1} and {2} +js.workbench.action.patch.short_description={0} patch +js.workbench.action.patch.description=Wove in {1} from {2} to {3} \u00B7 +{4} / \u2212{5} +js.workbench.selection_info.single.headline=Point details +js.workbench.selection_info.multi.headline={0} points selected +js.workbench.selection_info.tags.generated=generated +js.workbench.selection_info.tags.moved=moved +js.workbench.selection_info.tags.patched=patched in +js.workbench.selection_info.keys.stream=Stream +js.workbench.selection_info.keys.time=Time +js.workbench.selection_info.keys.clock=Clock +js.workbench.selection_info.keys.lat_lng=Lat / Lng +js.workbench.selection_info.keys.alt=Altitude +js.workbench.selection_info.keys.source_id=Source ID +js.workbench.selection_info.keys.ui_id=UI ID +js.workbench.selection_info.keys.range=Range +js.workbench.selection_info.keys.span=Span +js.workbench.selection_info.keys.generated=Generated +js.workbench.selection_info.keys.moved=Already moved + +js.workbench.commit.part.patches.title=Patches +js.workbench.commit.part.patches.count={0,choice,1#1 point|1<{0,number,integer} points} +js.workbench.commit.part.actions.count={0,choice,1#1 action|1<{0,number,integer} actions} +js.workbench.commit.part.deleted.title=Deleted +js.workbench.commit.part.moved.title=Moved + +workbench.commit.success=Journey saved successfully. +workbench.commit.failure=Failed to save your journey. {0} +js.workbench.commit.failure.network=Network error. Please try again. + + +js.workbench.timeline.main.title=Main Timeline +js.workbench.action.delete=Delete +js.workbench.action.center_map=Center map + +workbench.action.delete=Delete +workbench.action.delete.title=Delete selection +workbench.timeline.main.title=Main Timeline +workbench.timeline.main.sub=(generated) +workbench.timeline.woven_count=Woven Segments +workbench.timeline.device.title=Device +workbench.commit.irreversible=This action cannot be undone. All edits will be permanently stored. +common.actions.cancel=Cancel +workbench.commit.confirm=Save changes +workbench.commit.title=Save your changes + +jobs.timeline_stitching.friendly_name=Stitching {0} from {1} to {2} into timeline +jobs.recalculate_timeline.stitching.friendly_name=Recalculate your final timeline between {0} to {1} + + +metadata.mood.HAPPY.label=Happy +metadata.mood.RELAXED.label=Relaxed +metadata.mood.ADVENTUROUS.label=Adventurous +metadata.mood.TIRED.label=Tired +metadata.mood.STRESSED.label=Stressed + +metadata.panel.headline.visit=Visit to {0} ({1}) +metadata.panel.headline.trip=Trip between {0} \ No newline at end of file diff --git a/src/main/resources/messages_de.properties b/src/main/resources/messages_de.properties index 724b05907..d38d5c491 100644 --- a/src/main/resources/messages_de.properties +++ b/src/main/resources/messages_de.properties @@ -150,8 +150,6 @@ users.avatar.or=ODER users.oidc.managed.message=Dieser Benutzer wird von einem externen OIDC-Anbieter verwaltet. Benutzername und Anzeigename sind deaktiviert users.oidc.view.profile=Externes Profil anzeigen users.avatar.oidc.managed=Avatar wird von Ihrem OIDC-Anbieter verwaltet und automatisch aktualisiert. -map.colored.preference=Karte in Farbe anzeigen -map.colored.preference.description=Wenn aktiviert, wird die Karte in voller Farbe angezeigt. Wenn deaktiviert, wird die Karte in Graustufen dargestellt. # Units units.title=Einheitensystem @@ -1332,11 +1330,11 @@ geocoding.service.name.placeholder=Geben Sie einen Namen für den Dienst ein language.polish=Polnisch language.chinese=Chinesisch statistics.title.overall=Gesamtstatistik -statistics.title.year=Statistiken für {0} -statistics.title.month-year=Statistiken für {0} {1} -js.map.auto-update.enable.title=Auto-Update-Modus aktivieren -js.map.auto-update.disable.title=Auto-Update-Modus deaktivieren -js.map.fullscreen.toggle.title=Vollbild aktivieren +statistics.title.year=Statistiken f\u00FCr {0} +statistics.title.month-year=Statistiken f\u00FCr {0} {1} +map.auto-update.enable.title=Auto-Update-Modus aktivieren +map.auto-update.disable.title=Auto-Update-Modus deaktivieren +map.fullscreen.toggle.title=Vollbild aktivieren export.gpx.relevant=Nur relevante Daten exportieren? export.raw.data.loading=Standortdaten laden... label.warning=Warnung: diff --git a/src/main/resources/messages_es.properties b/src/main/resources/messages_es.properties index 07ef11c4b..49a46f84c 100644 --- a/src/main/resources/messages_es.properties +++ b/src/main/resources/messages_es.properties @@ -449,7 +449,6 @@ memory.form.description.label=Descripción upload.button.geojson=Subir Fichero GeoJSON memory.validation.end.date.required=Es necesario una fecha de fin visit.detection.search.distance.help=La distancia máxima entre puntos de ubicación debe considerarse parte de la misma visita. Valores más pequeños (50-100 m) detectan ubicaciones precisas, los valores más grandes (200-500 m) agrupan las ubicaciones cercanas. Valores típicos: 100 m para zonas urbanas, 200 m para zonas suburbanas. -map.colored.preference.description=Cuando esta activo, el mapa se muestra a todo color. Si esta desactivado, el mapa se muestra en escala de grises. memory.view.recalculate=Recalcular integrations.mqtt.password.placeholder=Contraseña mqtt login.username=Nombre de Usuario @@ -974,7 +973,6 @@ login.button=Login country.ga.label=Gabón users.avatar.label=Imagen del Perfil language.description=Seleccione su idioma preferido para la aplicación. Es necesario recargar la página para que los cambios tengan efecto. -map.colored.preference=Mostrar mapa en color jobs.title=Estado del Trabajo shared-with-me.title=Usuarios que comparten conmigo places.geocoding.response.back=Volver a Lugares @@ -1332,9 +1330,9 @@ priority.4.label=Bajo priority.5.label=Más bajo js.sse.error.connection-lost=¡Se ha perdido la conexión con el servidor! Intentando reconectar … js.map.auto-update.latest-location=Última localización -js.map.auto-update.enable.title=Entrado en Modo AutoActualización -js.map.auto-update.disable.title=Abandonando Modo AutoActualización -js.map.fullscreen.toggle.title=Pantalla Completa +map.auto-update.enable.title=Entrado en Modo AutoActualización +map.auto-update.disable.title=Abandonando Modo AutoActualización +map.fullscreen.toggle.title=Pantalla Completa js.map.popup.labels.total_duration=Duración Total: js.map.popup.labels.from=Desde js.map.popup.labels.to=Hasta diff --git a/src/main/resources/messages_fi.properties b/src/main/resources/messages_fi.properties index 631bbebcd..82dd158fb 100644 --- a/src/main/resources/messages_fi.properties +++ b/src/main/resources/messages_fi.properties @@ -135,8 +135,6 @@ users.avatar.or=TAI users.oidc.managed.message=Tätä käyttäjää hallinnoidaan ulkoisella OIDC-palveluntarjoajalla. Käyttäjänimi ja näyttönimi on poistettu käytöstä users.oidc.view.profile=Näytä ulkoinen profiili users.avatar.oidc.managed=Avataria hallinnoi OIDC-palveluntarjoajasi ja se päivitetään automaattisesti. -map.colored.preference=Näytä kartta värillisena -map.colored.preference.description=Kun käytössä, kartta näytetään täysvärisenä. Kun pois käytöstä, kartta näytetään harmaasävyisenä. # Units units.title=Yksikköjärjestelmä @@ -1058,9 +1056,9 @@ login.oauth.button=Kirjaudu käyttämällä OAuth statistics.title.overall=Kokonaistilastot statistics.title.year=Tilastot kohteelle {0} statistics.title.month-year=Tilastot kohteelle {0} {1} -js.map.auto-update.enable.title=Siiry Automaattipäivitys Tilaan -js.map.auto-update.disable.title=Poistu Automaattipäivitys tilasta -js.map.fullscreen.toggle.title=Kokonäytön tila +map.auto-update.enable.title=Siiry Automaattipäivitys Tilaan +map.auto-update.disable.title=Poistu Automaattipäivitys tilasta +map.fullscreen.toggle.title=Kokonäytön tila export.gpx.relevant=Vie vain käsittelyyn liittyvä tarpeellinen data? export.raw.data.loading=Ladataan sijatintidataa... export.raw.data.showing=Näytetään {0} - {1}, {2} diff --git a/src/main/resources/messages_fr.properties b/src/main/resources/messages_fr.properties index 84c10f41c..95207e501 100644 --- a/src/main/resources/messages_fr.properties +++ b/src/main/resources/messages_fr.properties @@ -142,8 +142,6 @@ units.metric=Métrique units.metric.description=(km, m) units.imperial=Impérial units.imperial.description=(mi, ft) -map.colored.preference=Afficher la carte en couleur -map.colored.preference.description=Lorsque activé, la carte sera affichée en couleur complète. Lorsque désactivé, la carte sera affichée en niveaux de gris. users.home.location.label=Localisation du domicile users.home.location.description=Définissez votre localisation de domicile. Cette localisation sera affichée lorsqu’aucune donnée n’est disponible pour la date sélectionnée. users.home.latitude.label=Latitude @@ -1321,9 +1319,9 @@ js.integrations.owntracks.recorder.loading.historical=Chargement des données d statistics.title.overall=Statistiques globales statistics.title.year=Statistiques pour {0} statistics.title.month-year=Statistiques pour {0} {1} -js.map.auto-update.enable.title=Activer le mode temps réel -js.map.auto-update.disable.title=Quitter le mode temps réel -js.map.fullscreen.toggle.title=Changer le plein-écran +map.auto-update.enable.title=Activer le mode temps réel +map.auto-update.disable.title=Quitter le mode temps réel +map.fullscreen.toggle.title=Changer le plein-écran export.gpx.relevant=Exporter seulement les données propres au traitement ? export.raw.data.loading=Chargement des données de localisation… label.warning=Avertissement : diff --git a/src/main/resources/messages_ja.properties b/src/main/resources/messages_ja.properties index 3a071a285..3399ea6f5 100644 --- a/src/main/resources/messages_ja.properties +++ b/src/main/resources/messages_ja.properties @@ -1401,9 +1401,9 @@ geocoding.table.type=タイプ message.success.geocode.updated=ジオコーディングサービスの更新に成功しました js.sse.error.connection-lost=サーバーとの接続が切れました。再接続中… js.map.auto-update.latest-location=最新の現在地 -js.map.auto-update.enable.title=自動更新モードを有効化 -js.map.auto-update.disable.title=自動更新モードを無効化 -js.map.fullscreen.toggle.title=全画面表示に切り替える +map.auto-update.enable.title=自動更新モードを有効化 +map.auto-update.disable.title=自動更新モードを無効化 +map.fullscreen.toggle.title=全画面表示に切り替える js.map.popup.labels.total_duration=合計時間: js.map.popup.labels.from=開始 js.map.popup.labels.to=終了 diff --git a/src/main/resources/messages_nl.properties b/src/main/resources/messages_nl.properties index a20660cc4..7a18d2d6b 100644 --- a/src/main/resources/messages_nl.properties +++ b/src/main/resources/messages_nl.properties @@ -537,8 +537,6 @@ js.users.custom.css.remove.confirm=Weet u zeker dat u het huidige CSS-bestand wi users.custom.css.error.to-large=CSS-bestand te groot. Maximale grootte is 1MB. users.custom.css.error.invalid-file-type=Ongeldig bestandstype. Alleen CSS-bestanden zijn toegestaan. users.custom.css.error.generic=Fout in verwerking CSS-bestand: {0} -map.colored.preference=Toon kaart in kleur -map.colored.preference.description=Wanneer ingeschakeld zal de kaart in kleur worden weergegeven. Wanneer uitgeschakeld zal de kaart in grijstinten worden weergegeven. units.title=Eenheden units.metric=Metrisch units.metric.description=(km, m) @@ -921,7 +919,7 @@ statistics.monthly.breakdown=Maandoverzicht statistics.daily.breakdown=Dagoverzicht statistics.transport.distribution=Verdeling vervoersmiddelen js.map.auto-update.latest-location=Nieuwste locatie -js.map.fullscreen.toggle.title=Volledig scherm in-/uitschakelen +map.fullscreen.toggle.title=Volledig scherm in-/uitschakelen export.title=Exporteer gegevens export.date.range=Datumbereik export.start.date=Startdatum @@ -1014,8 +1012,8 @@ geocoding.clear.button=Wissen en opnieuw gecoderen message.success.language.changed=Taal succesvol gewijzigd statistics.title.year=Statistieken voor {0} statistics.no.data=Geen gegevens beschikbaar -js.map.auto-update.enable.title=Schakel de automatische updatemodus in -js.map.auto-update.disable.title=Verlaat de automatische update-modus +map.auto-update.enable.title=Schakel de automatische updatemodus in +map.auto-update.disable.title=Verlaat de automatische update-modus export.gpx.button=Exporteer als GPX export.raw.data.title=Ruwe locatiegegevens export.raw.data.loading=Locatiegegevens laden... diff --git a/src/main/resources/messages_pt_BR.properties b/src/main/resources/messages_pt_BR.properties index 646501aa9..4ff84f8c1 100644 --- a/src/main/resources/messages_pt_BR.properties +++ b/src/main/resources/messages_pt_BR.properties @@ -129,8 +129,6 @@ users.avatar.delete=Remover Avatar users.avatar.default.title=Escolha um avatar padrão users.avatar.custom.title=Carregue uma imagem personalizada users.avatar.or=OU -map.colored.preference=Mostrar mapa colorido -map.colored.preference.description=Quando habilitado, o mapa será exibido em cores completas. Quando desabilitado, o mapa será mostrado em escala de cinza. # Unidades units.title=Sistema de Unidades diff --git a/src/main/resources/messages_zh_CN.properties b/src/main/resources/messages_zh_CN.properties index 0c3fa548d..01fbd23ca 100644 --- a/src/main/resources/messages_zh_CN.properties +++ b/src/main/resources/messages_zh_CN.properties @@ -423,8 +423,6 @@ users.avatar.delete=删除头像 users.avatar.default.title=选择默认头像 users.avatar.custom.title=上传自定义图片 users.avatar.or=或 -map.colored.preference=以彩色显示地图 -map.colored.preference.description=启用后,地图将以全彩显示。禁用后,地图将以灰度显示。 # \u5355\u4F4D units.title=单位系统 diff --git a/src/main/resources/static/css/avatar-marker.css b/src/main/resources/static/css/avatar-marker.css index 1041050c3..c0b941693 100644 --- a/src/main/resources/static/css/avatar-marker.css +++ b/src/main/resources/static/css/avatar-marker.css @@ -1,8 +1,8 @@ .avatar-marker { - width: 40px; - height: 40px; + width: 46px; + height: 46px; border-radius: 50%; - border: 3px solid #fff; + border: 3px solid #70707073;; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); overflow: hidden; background: #f0f0f0; @@ -28,8 +28,8 @@ left: 0; width: 100%; height: 100%; - padding-top: 9px; - padding-left: 1px; + text-align: center; + padding-top: 14px; font-size: 1.6rem; font-family: var(--serif-font); } diff --git a/src/main/resources/static/css/checkbox.css b/src/main/resources/static/css/checkbox.css index c6b5caaf6..11a950aa3 100644 --- a/src/main/resources/static/css/checkbox.css +++ b/src/main/resources/static/css/checkbox.css @@ -9,6 +9,7 @@ user-select: none; border-radius: 8px; transition: background 0.2s ease; + margin: 0; } .slide-reveal:hover { @@ -17,7 +18,7 @@ .slide-reveal .slide-box { position: relative; - width: 18px; + min-width: 18px; height: 18px; border: 2px solid var(--color-highlight); border-radius: 4px; diff --git a/src/main/resources/static/css/color-picker.css b/src/main/resources/static/css/color-picker.css new file mode 100644 index 000000000..3bebd232f --- /dev/null +++ b/src/main/resources/static/css/color-picker.css @@ -0,0 +1,98 @@ +.color-picker-section { + margin-top: 10px; +} + +.color-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); + gap: 12px; + margin-bottom: 15px; + max-width: 400px; +} + +.color-option { + cursor: pointer; + display: flex; + justify-content: center; +} + +.color-swatch { + width: 40px; + height: 40px; + border-radius: 8px; + border: 3px solid transparent; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.color-option input:checked + .color-swatch { + border-color: var(--color-text-white); + transform: scale(1.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.color-option:hover .color-swatch { + transform: scale(1.05); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15); +} + +.color-actions { + margin-top: 10px; +} + +.custom-color-swatch { + background: linear-gradient(45deg, #f0f0f0 25%, transparent 25%), + linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f0f0f0 75%), + linear-gradient(-45deg, transparent 75%, #f0f0f0 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--color-border-default); +} + +.custom-color-icon { + font-size: 20px; + color: var(--color-text-white); + font-weight: bold; +} + +.custom-color-input-section { + margin: 15px 0; + padding: 15px; + background-color: var(--color-bg-secondary); + border-radius: 8px; + border: 1px solid var(--color-border-default); +} + +.custom-color-input-section label { + display: block; + margin-bottom: 8px; + font-weight: bold; +} + +.custom-color-input-wrapper { + display: flex; + gap: 10px; + align-items: center; +} + +.custom-color-input-wrapper input[type="color"] { + width: 50px; + height: 40px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.custom-color-input-wrapper input[type="text"] { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--color-border-default); + border-radius: 4px; + background-color: var(--color-background-dark); + color: var(--color-text-white); + font-family: monospace; +} \ No newline at end of file diff --git a/src/main/resources/static/css/datetime-picker.css b/src/main/resources/static/css/datetime-picker.css index f474f6c3a..163d2f47e 100644 --- a/src/main/resources/static/css/datetime-picker.css +++ b/src/main/resources/static/css/datetime-picker.css @@ -65,7 +65,6 @@ /* Popup container */ .picker-popup { position: absolute; - top: 100%; left: -1px; z-index: 1000; background: var(--color-background-dark); @@ -76,6 +75,18 @@ min-width: 600px; } +.picker-popup.placement-bottom { + top: 100%; + bottom: auto; + margin-top: 4px; +} + +.picker-popup.placement-top { + top: auto; + bottom: 100%; + margin-bottom: 4px; +} + .picker-container { display: flex; padding: 16px; @@ -108,7 +119,7 @@ } .calendar-header button:hover { - background: #f0f0f0; + background: #3b3b3b; } .month-year { @@ -150,7 +161,6 @@ } .day:hover { - background: #767676; color: var(--color-highlight); } @@ -218,7 +228,9 @@ } .year-item:hover, .time-item:hover { - background: #767676; + background: var(--color-background-dark-light); + filter: none; + box-shadow: none; color: var(--color-highlight); } diff --git a/src/main/resources/static/css/inline-edit.css b/src/main/resources/static/css/inline-edit.css index 0915580cb..3c18661a3 100644 --- a/src/main/resources/static/css/inline-edit.css +++ b/src/main/resources/static/css/inline-edit.css @@ -5,13 +5,9 @@ .edit-icon { opacity: 0; - margin-left: 8px; transition: opacity 0.2s ease; cursor: pointer; font-size: 1.6rem; - position: absolute; - top: 0; - right: 0; background: gray; border: 1px solid wheat; color: wheat; @@ -19,6 +15,19 @@ text-decoration: none; } +.place-actions { + display: flex; + flex-direction: row; + top: 0; + position: absolute; + flex-grow: 0; + align-items: start; + gap: 8px; + right: 0; + text-shadow: none; + +} + .timeline-entry.trip.active:hover .edit-icon, .timeline-entry.active:hover .place-name-container .edit-icon { opacity: 1; diff --git a/src/main/resources/static/css/job-status.css b/src/main/resources/static/css/job-status.css new file mode 100644 index 000000000..152b05a89 --- /dev/null +++ b/src/main/resources/static/css/job-status.css @@ -0,0 +1,173 @@ +/* Job Status Page Styles */ + +.job-card { + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + border: 1px solid var(--color-highlight-transparent); + transition: all 0.3s ease, box-shadow 0.3s ease; +} + +.job-card.done { + padding: 8px 16px; +} + +.job-card:hover { + transform: translateY(-4px); + box-shadow: var(--box-shadow); +} + +.job-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} + +.job-card.done .job-card-header { + margin-bottom: 0; +} + +.job-card-title { + font-weight: 600; + font-size: 1.1em; + margin: 0; + color: var(--color-text-white); +} +.job-card.done .job-card-title { + flex-grow: 1; +} + +.job-card-description { + color: var(--color-text-white); + font-size: 0.9em; + margin: 4px 0; +} +.job-card.done .job-card-description { + margin-right: 16px; +} + +.job-card-meta { + display: flex; + gap: 16px; + font-size: 0.85em; + color: var(--color-text-white); + margin-top: 8px; + flex-wrap: wrap; +} + +.job-card.done .job-card-meta { + margin-top: 4px; + margin-right: 16px; +} + +.job-card-meta i { + margin-right: 4px; +} + +.job-progress { + margin-top: 12px; +} + +.job-progress-bar { + height: 8px; + background: var(--color-background-dark); + border-radius: 4px; + overflow: hidden; +} + +.job-progress-fill { + height: 100%; + background: var(--color-highlight); + transition: width 0.3s ease; +} + +.job-progress-text { + font-size: 0.85em; + color: var(--color-text-gray); + margin-top: 4px; +} + +.child-jobs-container { + margin-top: 12px; + border-top: 1px solid var(--color-highlight-transparent); + padding-top: 12px; +} + +.child-jobs-list { + display: block; + margin-top: 8px; + padding-left: 16px; +} + +.child-job-item { + padding: 8px; + background: var(--color-background-dark); + border-radius: 4px; + margin-bottom: 4px; + font-size: 0.9em; + color: var(--color-text-white); +} + +.job-duration { + font-weight: 500; + color: var(--color-highlight); +} + +.jobs-section { + margin-bottom: 24px; +} + +.jobs-section h3 { + margin-bottom: 12px; + font-size: 1.2em; + color: var(--color-highlight); +} + +.empty-state { + color: var(--color-text-gray); + font-style: italic; + padding: 16px; + text-align: center; +} + +.btn-sm { + padding: 4px 8px; + font-size: 0.85em; +} + +/* Job state badges */ +.job-state { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; +} + +.job-state-awaiting { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; +} + +.job-state-preparing { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; +} + +.job-state-running { + background: rgba(33, 150, 243, 0.2); + color: #2196f3; +} + +.job-state-completed { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; +} + +.job-state-failed { + background: rgba(244, 67, 54, 0.2); + color: #f44336; +} diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 6f7d975cd..0dbea1ee2 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -4,6 +4,8 @@ @import "map-controls.css"; @import "checkbox.css"; @import "map.css"; +@import "color-picker.css"; +@import "job-status.css"; /* fraunces-100 - latin_latin-ext_vietnamese */ @font-face { @@ -102,6 +104,9 @@ --color-background-dark-light: #5c5c5c; --color-text-white: #e3e3e3; --color-text-gray: #c0c0c0; + --color-text-black: #2e2e2e; + --color-warn: #ffeeab; + --color-error: #ffb4ab; --serif-font: "Fraunces", 'Noto Serif SC', 'PingFang SC', 'Microsoft YaHei', serif; --memories-header-height: 550px; --box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2); @@ -166,6 +171,19 @@ body { .navbar .nav-link:hover { background-color: rgba(255, 255, 255, 0.2); + filter: none; + box-shadow: none; +} + +.navigation-container.overlay { + width: 100%; +} + +.navigation-container.overlay .navbar { + background: var(--color-background-dark); + width: 100%; + padding: 0 32px; + margin: 0; } @media (min-width: 800px) { @@ -342,6 +360,129 @@ header { margin-bottom: 8px; } +.settings-secondary-btn, +.settings-primary-btn, +.settings-ghost-btn, +.settings-icon-btn { + color: var(--color-highlight); + border: 1px solid var(--color-highlight); +} + +.settings-secondary-btn, +.settings-primary-btn, +.settings-ghost-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 10px; +} + +.settings-primary-btn, +.btn.settings-primary-btn { + background: var(--color-highlight); + color: var(--color-background-dark); +} + +.settings-primary-btn i, +.settings-primary-btn span, +.btn.settings-primary-btn i, +.btn.settings-primary-btn span { + color: var(--color-background-dark); +} + +.settings-ghost-btn, +.btn.settings-ghost-btn { + background: transparent; + border-color: transparent; + color: var(--color-text-gray); +} + +.settings-ghost-btn:hover, +.btn.settings-ghost-btn:hover { + color: var(--color-text-white); +} + +.settings-icon-btn { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.settings-icon-btn.danger { + color: #ffb4ab; + border-color: #ffb4ab; +} + +.settings-toggle-switch { + display: inline-flex; + flex: 0 0 auto; + position: relative; +} + +.settings-toggle-switch input { + opacity: 0; + position: absolute; +} + +.settings-toggle-switch span { + background: var(--color-background-dark); + border: 1px solid var(--color-highlight); + border-radius: 999px; + cursor: pointer; + display: inline-block; + height: 24px; + position: relative; + transition: background 0.15s ease; + width: 44px; +} + +.settings-toggle-switch span::before { + background: var(--color-text-gray); + border-radius: 50%; + content: ""; + height: 18px; + left: 3px; + position: absolute; + top: 50%; + transform: translateY(-50%); + transition: transform 0.15s ease, background 0.15s ease; + width: 18px; +} + +.settings-toggle-switch input:checked + span { + background: var(--color-highlight); +} + +.settings-toggle-switch input:checked + span::before { + background: var(--color-background-dark); + transform: translateY(-50%) translateX(20px); +} + +.custom-map-style-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.custom-map-style-grid .form-group { + margin: 0; +} + +.custom-map-style-error { + display: none; + color: #ffb4ab; + font-size: 0.9rem; + margin-bottom: 10px; +} + +.custom-map-style-error.visible { + display: block; +} + /* Responsive adjustments for settings menu */ @media (max-width: 768px) { .settings-menu { @@ -369,8 +510,52 @@ header { .btn { background-color: var(--color-background-dark); font-family: var(--sans-serif-font); + transition: all ease 0.2s; + border-right: 4px; +} + + +.btn, +a.btn, +button { + font-size: 14px; + color: var(--color-highlight); + border: 1px solid var(--color-highlight); + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + text-decoration: none; + display: inline-block; +} + +.btn:hover, +a.btn:hover, +button:hover { + background-color: #525252; + filter: brightness(1.05); + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.5), 0 8px 28px rgba(217, 164, 65, 0.5), inset 0 1px 0 rgba(255, 245, 215, 0.7), inset 0 -1px 0 rgba(120, 80, 20, 0.3); +} + +.btn-muted { + filter: brightness(0.7); +} + +.btn-link { + background: transparent; + border: none; + padding: 0; + color: var(--color-highlight); + text-decoration: underline; + cursor: pointer; +} + +.btn-link:hover { + background-color: unset; + filter: brightness(1.2); + box-shadow: unset; } + a.btn-default, .btn-default { border: 1px solid var(--color-highlight); @@ -397,6 +582,16 @@ a.btn-default, font-weight: normal; } +.btn.btn-warning { + color: var(--color-text-black); + border-color: var(--color-text-black); + background: var(--color-warn); +} + +.btn.btn-warning:disabled { + backdrop-filter: saturate(10%); +} + fieldset { margin-bottom: 16px; border-radius: 4px; @@ -427,12 +622,17 @@ label { display: block; margin-bottom: 5px; font-weight: 500; + color: var(--color-text-white); } .toggle-buttons { display: inline-flex; } +.toggle-buttons.pills { + gap: 6px; +} + .toggle-buttons .btn { padding: 0.375rem 0.75rem; cursor: pointer; @@ -442,12 +642,14 @@ label { border: 1px solid var(--color-highlight); } -.toggle-buttons .btn:not(:last-child) { +.toggle-buttons .btn:not(:last-child), +.toggle-buttons label:not(:last-of-type) { border-bottom-right-radius: 0; border-top-right-radius: 0; } -.toggle-buttons .btn:not(:first-child) { +.toggle-buttons .btn:not(:first-child), +.toggle-buttons label:not(:first-of-type) { border-bottom-left-radius: 0; border-top-left-radius: 0; } @@ -457,6 +659,37 @@ label { box-shadow: var(--box-shadow); } +.toggle-buttons input[type="radio"] { + width: 0; + margin: 0; +} + +.toggle-buttons label { + background: var(--color-background-dark); + display: inline-block; + width: 100%; + background: #2a2a2a; + border: 1px solid var(--color-background-dark-light); + color: var(--color-text-white, #ccc); + padding: 6px 12px; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s ease-in-out; + border-radius: 4px; +} + +.toggle-buttons.pills label { + padding: 6px 12px; + border-radius: 20px; +} + +.toggle-buttons input[type="radio"]:checked + label, +.toggle-buttons label:hover { + border: 1px solid var(--color-highlight); + filter: brightness(1.1); + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.5), 0 8px 28px rgba(217, 164, 65, 0.5), inset 0 1px 0 rgba(255, 245, 215, 0.7), inset 0 -1px 0 rgba(120, 80, 20, 0.3); +} + input[type="text"], input[type="password"], input[type="url"], input[type="number"], input[type="date"], select, textarea { width: 100%; padding: 8px; @@ -529,6 +762,51 @@ tr:hover { background-color: var(--color-background-dark-light); } +/* Tags */ + +.tags-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.tag-chip { + transition: all 0.15s ease-in-out; + font-size: 0.9rem; + display: inline-block; + background: #2a2a2a; + border: 1px solid var(--color-highlight); + color: var(--color-text-white); + padding: 6px 12px; + border-radius: 20px; + white-space: nowrap; + box-shadow: 0 0 1px 0 var(--color-highlight) inset, 0 0 1px 0 var(--color-highlight); +} + +.tag-chip:hover { + border: 1px solid var(--color-highlight); + filter: brightness(1.05); + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.5), 0 8px 28px rgba(217, 164, 65, 0.5), inset 0 1px 0 rgba(255, 245, 215, 0.7), inset 0 -1px 0 rgba(120, 80, 20, 0.3); +} + +.tag-chip .tag-remove { + margin-left: 6px; + cursor: pointer; + font-weight: bold; + opacity: 0.8; +} + +.tag-chip .tag-remove:hover { + opacity: 1; +} + +.form-description { + color: var(--color-text-white); + font-size: 0.9em; + margin-top: 5px; + font-style: italic; +} /* Alerts */ .alert { @@ -627,7 +905,6 @@ tr:hover { .divider { display: flex; align-items: center; - font-size: 1.3rem; color: var(--color-highlight); font-weight: 500; margin: 20px 0; @@ -827,6 +1104,69 @@ tr:hover { text-shadow: 0px 0px 0px white, 2px 2px 0px #24201c, 1px 1px 0 #171414; } +.timeline-container .aggregated-entry .overview-container { + display: flex; + gap: 1rem; + padding: 16px; +} + +.timeline-container .aggregated-entry .amount-overview { + display: flex; + gap: 1rem; + text-align: center; + padding-top: 24px; +} + +.timeline-container .aggregated-entry .activity-overview { + border-right: 1px solid var(--color-highlight); + padding-right: 8px; +} + +.timeline-container .aggregated-entry .amount-overview .overview-icon { + font-size: 2rem; + color: var(--color-highlight); +} + +.timeline-container .aggregated-entry .amount-overview .overview-amount { + font-size: 2rem; +} + +.timeline-container .aggregated-entry .headline .overview-link { + color: white; + text-decoration: navajowhite; + border-bottom: 1px solid var(--color-highlight); + display: flex; + font-size: 1.8rem; + margin-bottom: 8px; + padding: 4px 16px; +} + +.timeline-container .aggregated-entry .headline .overview-link .overview-name { + flex-grow: 1; +} + +.timeline-container .aggregated-entry { + border-radius: 8px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(12px); + background-color: rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: var(--box-shadow); + padding: 0; + color: #fff; + left: 24px; + margin-bottom: 24px; + text-shadow: 0 0 0 white, 2px 2px 0 #24201c, 1px 1px 0 #171414; + +} + +.timeline-container .aggregated-entry .sub-headline { + margin-bottom: 8px; + color: var(--color-text-gray); + font-size: 1.1rem; + padding: 4px 16px; +} + .htmx-indicator { display: none; } @@ -840,23 +1180,6 @@ tr:hover { height: initial; } -a.btn, -button { - font-size: 14px; - color: var(--color-highlight); - border: 1px solid var(--color-highlight); - padding: 8px 12px; - border-radius: 4px; - cursor: pointer; - text-decoration: none; - display: inline-block; -} - -a.btn:hover, -button:hover { - background-color: #959595; -} - .upload-options progress { display: block; width: 100%; @@ -1196,6 +1519,14 @@ body.auto-update-mode .today-fab { text-align: left; } +.text-align-center { + text-align: center; +} + +.text-gray { + color: var(--color-text-gray); +} + /* Settings Page Styles */ .settings-page { background-color: var(--color-background-dark); @@ -1781,13 +2112,52 @@ button:disabled { padding-left: 42px; } +#auto-update-user-selection .device-header, +#auto-update-user-selection .user-header { + text-shadow: 0 0 0 white, 2px 2px 0 #24201c, 1px 1px 0 #171414; +} + +#auto-update-user-selection .device-menu { + top: -54px; +} + +#auto-update-user-selection .user-header i { + text-shadow: none; +} +#auto-update-user-selection { + z-index: 2000; + position: fixed; + bottom: 0; + transition: opacity 0.3s ease-in-out; + line-height: 1.5; +} + +#auto-update-user-selection.hidden, +#auto-update-user-selection.hidden .user-header { + opacity: 0; + pointer-events: none; +} + +#auto-update-user-selection .user-timeline-section { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + background-color: var(--color-background-dark); + border-top: 1px solid var(--color-highlight); + width: 100%; + box-sizing: border-box; +} .user-timeline-section .no-entries { color: white; padding: 48px; } +.device-header, .user-header { + position: relative; pointer-events: all; + cursor: pointer; color: white; font-family: var(--serif-font); font-weight: normal; @@ -1795,14 +2165,36 @@ button:disabled { text-align: center; } +.device-header .username, .user-header .username { overflow: hidden; white-space: nowrap; max-width: 80px; text-overflow: ellipsis; } +.user-follow-badge { + opacity: 0; + border-radius: 50%; + width: 20px; + height: 20px; + aspect-ratio: 1; + display: inline-block; + position: absolute; + right: 0; + border: 2px solid var(--color-highlight); + background: var(--color-background-dark); + transition: opacity 0.3s ease-in-out; + color: var(--color-highlight); + font-size: 1rem; + box-shadow: var(--box-shadow); + top: 0; +} +.user-follow-badge.active { + opacity: 1; +} +.device-header.active .avatar, .user-header.active .avatar { border: 2px solid var(--color-highlight); box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3); @@ -1915,7 +2307,7 @@ button:disabled { left: 0; right: 0; height: 1px; - background-color: #ddd; + background: linear-gradient(to right, transparent, var(--color-highlight) 20%, var(--color-highlight) 80%, transparent); z-index: 1; } @@ -2224,4 +2616,101 @@ button:disabled { opacity: 1; } +/* Autocomplete dropdown */ +.autocomplete { + position: relative; +} +.suggestions-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--color-background-dark, #1e1e1e); + border: 1px solid var(--color-highlight, #444); + border-top: none; + max-height: 200px; + overflow-y: auto; + z-index: 20; + border-radius: 0 0 8px 8px; +} +.suggestions-dropdown:empty { + display: none; +} +.suggestion-item { + padding: 6px 12px; + cursor: pointer; + color: var(--color-text-white, #ccc); +} +.suggestion-item.active, +.suggestion-item:hover { + background: var(--color-background-dark-light, #4da6ff); + color: white; + border: 1px solid var(--color-highlight); + filter: brightness(1.1); + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.5), 0 8px 28px rgba(217, 164, 65, 0.5), inset 0 1px 0 rgba(255, 245, 215, 0.7), inset 0 -1px 0 rgba(120, 80, 20, 0.3); + +} + +.hidden { + display: none; +} + +.device-menu .device-icon { + height: 24px; + min-width: 12px; + display: inline-block; + border-radius: 4px; + margin-right: 8px; +} + +.device-toggle-container { + position: absolute; + display: inline-block; + bottom: 24px; + right: 0; +} + +.device-toggle-btn { + border-radius: 8px; + width: 25px; + height: 18px; + background: var(--color-background-dark); + padding: 0; + margin: 0; + border-width: 2px; +} + +.device-toggle-btn:hover { + color: var(--color-highlight, #fff); +} + +.device-menu { + position: absolute; + z-index: 500; + min-width: 300px; + background: var(--color-background-dark, #1e1e1e); + border: 1px solid var(--color-highlight, #444); + border-radius: 8px; + padding: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +.device-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + white-space: nowrap; +} + +.device-item:hover { + background: rgba(255,255,255,0.05); + border-radius: 4px; +} + +.device-name { + font-size: 13px; + color: var(--color-text-white); +} diff --git a/src/main/resources/static/css/map-controls.css b/src/main/resources/static/css/map-controls.css index 36826d987..58a40e909 100644 --- a/src/main/resources/static/css/map-controls.css +++ b/src/main/resources/static/css/map-controls.css @@ -43,10 +43,46 @@ white-space: nowrap; } -.map-controls button i { +.map-controls button i, +.map-style-selector i { font-size: 1.3rem; } +.map-style-selector { + display: flex; + gap: 4px; + align-items: center; + color: var(--color-primary); + padding: 4px 8px; + position: relative; + white-space: nowrap; +} + +.map-style-selector::after { + content: ""; + position: absolute; + right: 16px; + width: 7px; + height: 7px; + border-right: 2px solid rgba(245, 222, 179, 0.55); + border-bottom: 2px solid rgba(245, 222, 179, 0.55); + pointer-events: none; + transform: translateY(-2px) rotate(45deg); +} + +.map-style-selector select { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + min-width: 150px; + color: var(--color-primary); + background: var(--color-background-dark-light); + border: 1px solid var(--color-primary); + border-radius: 4px; + padding: 4px 30px 4px 8px; + font: inherit; +} + .map-control-btn.active { background: var(--color-background-dark-light); } diff --git a/src/main/resources/static/css/map.css b/src/main/resources/static/css/map.css index 164d70ef9..96a02847d 100644 --- a/src/main/resources/static/css/map.css +++ b/src/main/resources/static/css/map.css @@ -150,7 +150,6 @@ details.maplibregl-ctrl-attrib[open] { background: rgba(255, 255, 255, 0.95) !important; border-radius: 8px !important; - padding: 5px 12px; } /* Remove the default arrow in some browsers */ @@ -167,3 +166,93 @@ details.maplibregl-ctrl-attrib[open] { width: 50% } } + +.maplibregl-popup-content { + z-index: 10; + min-width: 280px; + max-width: 500px; + padding: 0; + font-size: 0.8rem; + backdrop-filter: blur(14px); + border: 1px solid var(--panel-line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.map-popup { + padding: 10px 12px; +} + +.sel-info-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(68, 68, 68, 0.43); +} + +.sel-info-title { + font-weight: 600; + font-size: 11.5px; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.sel-info-close { + background: transparent; + border: none; + color: var(--color-background-dark); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + border-radius: 4px; +} + +.sel-info-close:hover { + color: #ece6d6; + background: rgba(255, 255, 255, 0.08); +} + +.sel-info-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin: 3px 0; + line-height: 1.45; +} + +.sel-info-row .k { + color: var(--color-background-dark-light); + flex-shrink: 0; +} + +.sel-info-row .v { + color: var(--color-background-dark); + text-align: right; + font-variant-numeric: tabular-nums; + word-break: break-all; +} + +.sel-info-row .v.mono { + font-family: ui-monospace, monospace; +} + +.sel-info-tag { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 10px; + font-weight: 500; + letter-spacing: 0.2px; +} + +.sel-info-tag .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); +} diff --git a/src/main/resources/static/css/memories.css b/src/main/resources/static/css/memories.css index 96f23d155..1e4307405 100644 --- a/src/main/resources/static/css/memories.css +++ b/src/main/resources/static/css/memories.css @@ -756,8 +756,4 @@ color: var(--color-highlight); /* Highlight the labels */ font-weight: 600; margin-right: 4px; -} - -.maplibregl-popup-anchor-top .maplibregl-popup-tip { - border-bottom-color: var(--color-background-dark-light); } \ No newline at end of file diff --git a/src/main/resources/static/css/workbench.css b/src/main/resources/static/css/workbench.css new file mode 100644 index 000000000..86b3b210d --- /dev/null +++ b/src/main/resources/static/css/workbench.css @@ -0,0 +1,1110 @@ +:root { + --bg: #0e1826; + --bg-deep: #0a1320; + --panel: rgba(22, 32, 48, 0.78); + --panel-solid: #162030; + --ink: #ece6d6; + --ink-dim: #a5a99f; + --ink-faint: #6b7285; + --gold: #d9a441; + --gold-bright: #f2c470; + --gold-deep: #b8862c; + --gold-glow: rgba(217, 164, 65, 0.55); + --sky: #8fc5e6; + --sky-dim: #5a8bb0; + --violet: #b89adc; + --slate: #7e8a9e; + --danger: #e07a6b; + + --drawer-h: 320px; + --drawer-inset: 16px; + --connector-h: 36px; + --panel-line: rgba(212, 175, 108, 0.14); + --drawer-top-edge: calc(var(--drawer-h) + var(--drawer-inset)); + +} + +#drawer .device-select { + border-color: var(--color-highlight); +} + +#drawer .datetime-picker { + height: 28px; + border-color: var(--color-highlight); +} + +#drawer .datetime-picker .picker-trigger { + height: 28px; +} + +.drawer-head .time { + color: var(--color-highlight); + letter-spacing: 0.04em; +} + +.floating { + backdrop-filter: blur(14px); + border: 1px solid var(--panel-line); + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 15px 11px; + border-bottom: 1px solid var(--panel-line); +} + +.history-header h4 { + margin: 0; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.history-count { + background: var(--color-background-dark); + color: var(--color-text-white); + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + font-family: ui-monospace, monospace; + border: 1px solid rgba(217, 164, 65, 0.2); +} + +.history-clear { + font-size: 10px; + cursor: pointer; + padding: 3px 7px; + border-radius: 5px; + transition: all 0.15s; + letter-spacing: 0.04em; +} + +.history-clear:hover { + background: var(--color-background-dark); + color: var(--color-text-white); +} + +.history-list { + overflow-y: auto; + padding: 5px 5px 8px; + flex: 1; +} + +.history-list::-webkit-scrollbar { + width: 5px; +} + +.history-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; +} + +.history-empty { + padding: 26px 16px; + text-align: center; + font-size: 12px; + font-style: italic; + line-height: 1.55; + font-family: var(--serif-font); +} + +.history-item { + display: flex; + gap: 9px; + padding: 9px 10px; + border-radius: 8px; + margin: 3px 0; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.03); + animation: historyIn 0.28s ease-out; + transition: background 0.15s; + position: relative; +} + +@keyframes historyIn { + from { + opacity: 0; + transform: translateX(10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.history-item:hover { + background: rgba(255, 255, 255, 0.04); +} + +.history-item.undone { + opacity: 0.4; +} + +.history-item.undone .history-desc { + text-decoration: line-through; + text-decoration-color: rgba(224, 122, 107, 0.6); +} + +.history-item::before { + content: ''; + position: absolute; + left: 0; + top: 10%; + bottom: 10%; + width: 2px; + border-radius: 0 2px 2px 0; +} + +.history-item.type-copy::before { + background: var(--sky); +} + +.history-item.type-delete::before { + background: var(--danger); +} + +.history-item.type-move::before { + background: var(--gold); +} + +.history-icon { + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.04); + margin-top: 1px; +} + +.history-body { + flex: 1; + min-width: 0; +} + +.history-desc { + line-height: 1.4; + font-size: 0.8rem; +} + +.history-meta { + font-size: 0.9rem; + color: var(--color-text-gray); + font-family: ui-monospace, monospace; + margin-top: 3px; + letter-spacing: 0.03em; +} + +.history-undo { + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--color-text-gray); + padding: 3px 8px; + border-radius: 5px; + cursor: pointer; + font-size: 10px; + font-weight: 500; + transition: all 0.15s; + display: inline-flex; + align-items: center; + gap: 3px; + height: fit-content; +} + +.history-undo:hover:not(:disabled) { + color: var(--gold-bright); + border-color: rgba(217, 164, 65, 0.3); +} + +.history-undo:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.weave-patch-btn { + position: absolute; + transform: translate(-50%, 0); + padding: 7px 14px 7px 12px; + font-family: var(--serif-font); + cursor: pointer; + pointer-events: auto; + align-items: center; + gap: 7px; + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.4), 0 6px 22px rgba(217, 164, 65, 0.45), 0 0 30px rgba(217, 164, 65, 0.25), inset 0 1px 0 rgba(255, 245, 215, 0.6), inset 0 -1px 0 rgba(120, 80, 20, 0.3); + transition: box-shadow 0.2s, filter 0.15s; + z-index: 6; +} + +.weave-patch-btn:hover { + filter: brightness(1.06); + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.55), 0 8px 28px rgba(217, 164, 65, 0.6), 0 0 40px rgba(217, 164, 65, 0.4), inset 0 1px 0 rgba(255, 245, 215, 0.7), inset 0 -1px 0 rgba(120, 80, 20, 0.3); +} + +.weave-patch-btn:active { + transform: translate(-50%, 1px); +} + +.weave-patch-btn svg { + flex-shrink: 0; +} + +.weave-patch-btn.disabled { + opacity: 0.45; + filter: grayscale(0.3); + cursor: not-allowed; +} + + +.weave-btn { + width: 100%; + padding: 12px 14px; + font-family: var(--serif-font); + transition: all 0.18s; + position: relative; + overflow: hidden; +} + +.weave-btn:hover { + filter: brightness(1.05); + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.5), 0 8px 28px rgba(217, 164, 65, 0.5), + inset 0 1px 0 rgba(255, 245, 215, 0.7), inset 0 -1px 0 rgba(120, 80, 20, 0.3); +} + +.weave-btn:active { + transform: translateY(1px); +} + + +.zoom-indicator { + position: absolute; + bottom: calc(var(--drawer-top-edge) + 14px); + left: 18px; + padding: 7px 12px; + font-size: 0.8rem; + font-family: ui-monospace, monospace; + z-index: 6; +} + + +.selection-info { + position: absolute; + bottom: 342px; + right: 18px; + z-index: 10; + min-width: 240px; + max-width: 320px; + padding: 10px 12px; + font-size: 0.8rem; + pointer-events: auto; +} + +.sel-info-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(68, 68, 68, 0.43); +} + +.sel-info-title { + font-weight: 600; + font-size: 11.5px; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.sel-info-close { + background: transparent; + border: none; + color: var(--color-background-dark); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + border-radius: 4px; +} + +.sel-info-close:hover { + color: #ece6d6; + background: rgba(255, 255, 255, 0.08); +} + +.sel-info-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin: 3px 0; + line-height: 1.45; +} + +.sel-info-row .k { + color: var(--color-background-dark-light); + flex-shrink: 0; +} + +.sel-info-row .v { + color: var(--color-background-dark); + text-align: right; + font-variant-numeric: tabular-nums; + word-break: break-all; +} + +.sel-info-row .v.mono { + font-family: ui-monospace, monospace; +} + +.sel-info-tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 10px; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.2px; +} + +.sel-info-tag .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); +} + +.sel-info-tag.synthetic { + background: rgba(224, 122, 107, 0.15); + color: #e07a6b; + border: 1px solid rgba(224, 122, 107, 0.3); +} + +.sel-info-tag.moved { + background: rgba(217, 164, 65, 0.15); + color: #d9a441; + border: 1px solid rgba(217, 164, 65, 0.3); +} + +.sel-info-tag.patched { + background: rgba(143, 197, 230, 0.15); + color: #8fc5e6; + border: 1px solid rgba(143, 197, 230, 0.3); +} + +.sel-info-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.sel-info-actions { + display: flex; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.sel-info-btn { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #ece6d6; + padding: 5px 8px; + border-radius: 5px; + font-size: 11px; + cursor: pointer; +} + +.sel-info-btn:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} + +.weave-badge { + position: absolute; + top: -6px; + right: -6px; + bottom: -6px; + background: #cd1b0069;; + color: white; + min-width: 20px; + text-align: center; + letter-spacing: 0; + font-size: 0.8rem; + padding: 19px 8px; +} + +#map { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.maplibregl-ctrl-attrib { + background: rgba(14, 24, 38, 0.7) !important; + color: var(--ink-faint) !important; + font-size: 10px !important; +} + +.maplibregl-ctrl-attrib a { + color: var(--ink-faint) !important; +} + +#map:not(.inspect) .maplibregl-canvas { + cursor: crosshair; +} +.maplibregl-ctrl-top-left { + top: 44px; +} + +.maplibregl-ctrl-group { + border-radius: 10px !important; +} + +.maplibregl-ctrl-group button { + background: transparent !important; +} + +.maplibregl-ctrl-icon { + filter: invert(0.85) sepia(0.2) hue-rotate(-10deg); +} + + +#historyPanel { + position: absolute; + top: 54px; + right: 18px; + width: 290px; + max-height: calc(100vh - var(--drawer-top-edge) - 50px); + display: flex; + flex-direction: column; + z-index: 6; + overflow: hidden; +} + +.help-panel { + position: fixed; + inset: 0; + z-index: 1000; + display: none; + align-items: center; + justify-content: center; +} + +.help-panel.open { + display: flex; +} + +.help-panel-backdrop { + position: absolute; + inset: 0; + background: rgba(10, 19, 32, 0.55); + backdrop-filter: blur(3px); +} + +.help-panel-card { + position: relative; + width: min(540px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + background: var(--color-background-dark); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + color: #ece6d6; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.help-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.help-panel-head h2 { + margin: 0; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: #f2c470; +} +.help-panel-close { + background: transparent; + border: none; + color: rgba(236, 230, 214, 0.6); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0 6px; + border-radius: 4px; +} +.help-panel-close:hover { + color: #ece6d6; + background: rgba(255, 255, 255, 0.08); +} + +.help-panel-body { + padding: 14px 18px 18px; + overflow-y: auto; +} + +.help-section { + margin-bottom: 18px; +} +.help-section:last-of-type { + margin-bottom: 0; +} +.help-section h3 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: rgba(165, 169, 159, 0.85); +} + +.help-section dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 18px; + margin: 0; +} +.help-section dt { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + font-size: 12px; +} +.help-section dd { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: rgba(236, 230, 214, 0.92); + align-self: center; +} + +.help-section kbd { + display: inline-block; + min-width: 22px; + padding: 2px 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.3; + text-align: center; + color: #ece6d6; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + border-bottom-width: 2px; + border-radius: 4px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3); +} + +.help-footnote { + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 11.5px; + color: rgba(165, 169, 159, 0.75); + line-height: 1.5; +} +.help-footnote kbd { + font-size: 10.5px; + padding: 1px 5px; +} +/* ============ BOTTOM DRAWER ============ */ +#drawer { + position: fixed; + bottom: var(--drawer-inset); + left: var(--drawer-inset); + right: var(--drawer-inset); + height: var(--drawer-h); + background: var(--color-background-dark); + backdrop-filter: blur(18px); + border: 1px solid var(--panel-line); + border-radius: 8px; + z-index: 10; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + display: grid; + grid-template-rows: auto 1fr; +} + +.drawer-head { + display: flex; + align-items: center; + gap: 14px; + padding: 11px 18px; + border-bottom: 1px solid var(--panel-line); + flex-shrink: 0; +} + +.drawer-head .sep { + width: 1px; + height: 20px; + background: var(--panel-line); + margin: 0 4px; +} + +.head-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 11px; + background: rgba(255, 255, 255, 0.03); + height: 32px; +} + +.head-btn:hover { + background: rgba(255, 255, 255, 0.07); + color: var(--ink); +} + +.head-btn.active { + background: rgba(217, 164, 65, 0.18); + color: var(--color-highlight); + border-color: rgba(217, 164, 65, 0.3); + box-shadow: var(--box-shadow); +} +.head-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.close-x { + color: var(--ink-faint); + cursor: pointer; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.15s; +} + +.close-x:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--ink); +} + +/* 3-row body: main · connector · device */ +.drawer-body { + display: grid; + grid-template-columns: 170px 1fr 200px; + grid-template-rows: 1fr var(--connector-h) 1fr; + gap: 6px 14px; + padding: 12px 18px 14px; + min-height: 0; + overflow: hidden; +} + +.lane-label-cell { + display: flex; + flex-direction: column; + justify-content: center; + padding: 4px 0; + min-height: 0; +} + +.lane-label-cell .title { + font-family: var(--serif-font); + font-weight: 500; + font-size: 15px; + color: var(--ink); + letter-spacing: -0.005em; +} + +.lane-label-cell .sub { + font-size: 11px; + color: var(--ink-faint); + margin-top: 2px; + font-style: italic; + font-family: var(--serif-font); +} + +.lane-canvas-cell { + position: relative; + border-radius: 10px; + background: rgba(10, 17, 28, 0.55); + border: 1px solid rgba(255, 255, 255, 0.04); + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + cursor: grab; +} + +.lane-canvas-cell:active { + cursor: grabbing; +} + +/* The canvas will be sized dynamically in JS based on zoom */ +canvas { + display: block; + height: 100%; +} + +.lane-canvas-cell canvas { + display: block; + width: 100%; + height: 100%; +} + +/* Connector row: tracks the patch horizontally */ +.connector-row { + grid-column: 2 / 3; + grid-row: 2 / 3; + position: relative; + overflow: visible; + pointer-events: none; +} + +.connector-guide { + position: absolute; + top: 0; + bottom: 0; + width: 0; + border-left: 1.5px dashed rgba(242, 196, 112, 0.35); + pointer-events: none; +} + +/* Patch selector */ +#patchBox { + position: absolute; + top: 4px; + bottom: 4px; + background: linear-gradient(180deg, rgba(242, 196, 112, 0.14), rgba(217, 164, 65, 0.08)); + border: 1.5px solid var(--gold); + border-radius: 6px; + cursor: move; + box-sizing: border-box; + z-index: 4; + box-shadow: 0 0 0 1px rgba(242, 196, 112, 0.15), + 0 0 20px rgba(217, 164, 65, 0.35), + inset 0 1px 0 rgba(255, 235, 190, 0.3); +} + +.patch-handle { + position: absolute; + top: -1px; + bottom: -1px; + width: 6px; + background: linear-gradient(180deg, var(--gold-bright), var(--gold-deep)); + cursor: ew-resize; + box-shadow: 0 0 8px rgba(217, 164, 65, 0.5); +} + +.patch-handle.left { + left: -3px; + border-radius: 3px 0 0 3px; +} + +.patch-handle.right { + right: -3px; + border-radius: 0 3px 3px 0; +} + +.patch-handle::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 1.5px; + height: 14px; + background: rgba(255, 255, 255, 0.7); + border-radius: 1px; +} + +.patch-timestamps { + position: absolute; + bottom: 3px; + left: 6px; + right: 6px; + display: flex; + justify-content: space-between; + pointer-events: none; + font-family: ui-monospace, monospace; + font-size: 9.5px; + color: var(--gold-bright); + text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +#playhead { + position: absolute; + top: 8px; + bottom: 0; + width: 1.5px; + background: linear-gradient(180deg, var(--danger), rgba(224, 122, 107, 0.3)); + pointer-events: none; + display: none; + z-index: 5; + box-shadow: 0 0 10px rgba(224, 122, 107, 0.5); +} + +#playhead::before { + content: ''; + position: absolute; + top: -1px; + left: -4px; + width: 9px; + height: 9px; + background: var(--danger); + border-radius: 50%; + box-shadow: 0 0 10px rgba(224, 122, 107, 0.6); +} + +.right-cell { + display: flex; + align-items: center; + justify-content: center; + min-height: 0; +} + +.woven-count { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + padding: 8px 14px; + border-radius: 10px; + text-align: center; + width: 100%; +} + +.woven-count .num { + font-family: var(--serif-font); + font-size: 22px; + font-weight: 500; + color: var(--color-highlight); + line-height: 1; +} + +.woven-count .label { + font-size: 9.5px; + color: var(--color-text-white); + margin-top: 4px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + + +#boxSelectOverlay { + position: absolute; + border: 1.5px dashed var(--color-highlight); + background: rgba(242, 196, 112, 0.15); + pointer-events: none; + display: none; + z-index: 20; + border-radius: 3px; + box-shadow: 0 0 20px rgba(217, 164, 65, 0.3); +} + +.toast { + position: fixed; + bottom: calc(var(--drawer-top-edge) + 20px); + left: 50%; + transform: translateX(-50%); + background: linear-gradient(180deg, rgba(34, 48, 68, 0.95), rgba(22, 32, 48, 0.95)); + backdrop-filter: blur(12px); + color: var(--ink); + padding: 12px 20px; + border-radius: 10px; + font-size: 13px; + border: 1px solid rgba(217, 164, 65, 0.3); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(217, 164, 65, 0.15); + z-index: 100; + animation: toastIn 0.3s ease-out; + font-family: var(--serif-font); + font-weight: 500; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translate(-50%, 10px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +#commitModal { + position: fixed; + inset: 0; + background: rgba(10, 17, 28, 0.85); + backdrop-filter: blur(6px); + z-index: 200; + display: none; + align-items: center; + justify-content: center; +} + +#commitModal.open { + display: flex; +} + +.commit-dialog { + background: var(--panel-solid); + border: 1px solid var(--panel-line); + border-radius: 14px; + width: 680px; + max-height: 82vh; + display: flex; + flex-direction: column; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7); + overflow: hidden; +} + +.commit-header { + padding: 18px 22px; + border-bottom: 1px solid var(--panel-line); + display: flex; + justify-content: space-between; + align-items: center; +} + +.commit-header .title { + font-family: var(--serif-font); + font-weight: 500; + font-size: 18px; + color: var(--ink); +} + +.commit-header .sub { + font-size: 12px; + color: var(--color-text-white); + margin-top: 3px; + font-family: var(--serif-font); + font-style: italic; +} + +.commit-body { + flex: 1; + overflow: auto; + padding: 18px 22px; +} + +.commit-body pre { + background: rgba(10, 17, 28, 0.6); + padding: 14px 16px; + border-radius: 8px; + font-size: 11px; + color: var(--ink-dim); + overflow: auto; + margin: 0; + font-family: ui-monospace, monospace; + line-height: 1.55; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.commit-footer { + padding: 14px 22px; + border-top: 1px solid var(--panel-line); + display: flex; + gap: 10px; + justify-content: flex-end; +} + +/* Commit summary */ +.commit-summary-detail { + margin-bottom: 22px; +} + +.summary-section { + margin-bottom: 16px; +} + +.summary-section-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--gold-bright); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.summary-section-title::before { + content: '▸'; + font-size: 10px; +} + +.summary-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 0; + font-size: 12.5px; + color: var(--ink-dim); + margin-left: 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.summary-item .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.summary-item .desc { + flex: 1; +} + +.summary-item .count { + font-family: ui-monospace, monospace; + color: var(--ink); + font-size: 11px; + font-weight: 500; + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; +} + +.summary-footnote { + font-size: 12px; + color: var(--ink-dim); + margin-left: 14px; + padding: 5px 0; + font-style: italic; +} + +/* Irreversible warning */ +.commit-irreversible-warning { + display: flex; + align-items: flex-start; + gap: 8px; + color: var(--danger); + font-size: 12.5px; + line-height: 1.4; + margin-bottom: 12px; + padding: 10px 12px; + background: rgba(224, 122, 107, 0.08); + border: 1px solid rgba(224, 122, 107, 0.25); + border-radius: 8px; +} + +.warning-icon { + font-size: 14px; + margin-top: 1px; +} + + diff --git a/src/main/resources/static/img/avatars/default/gps.jpg b/src/main/resources/static/img/avatars/default/gps.jpg new file mode 100644 index 000000000..dc6435220 Binary files /dev/null and b/src/main/resources/static/img/avatars/default/gps.jpg differ diff --git a/src/main/resources/static/img/avatars/default/phone.jpg b/src/main/resources/static/img/avatars/default/phone.jpg new file mode 100644 index 000000000..efc659e17 Binary files /dev/null and b/src/main/resources/static/img/avatars/default/phone.jpg differ diff --git a/src/main/resources/static/img/avatars/default/phone.png b/src/main/resources/static/img/avatars/default/phone.png new file mode 100644 index 000000000..08fd586f5 Binary files /dev/null and b/src/main/resources/static/img/avatars/default/phone.png differ diff --git a/src/main/resources/static/img/avatars/default/smartwatch.jpg b/src/main/resources/static/img/avatars/default/smartwatch.jpg new file mode 100644 index 000000000..d8f3ae80e Binary files /dev/null and b/src/main/resources/static/img/avatars/default/smartwatch.jpg differ diff --git a/src/main/resources/static/js/LiveModeController.js b/src/main/resources/static/js/LiveModeController.js index 9073c77e1..56f01806c 100644 --- a/src/main/resources/static/js/LiveModeController.js +++ b/src/main/resources/static/js/LiveModeController.js @@ -77,6 +77,10 @@ class LiveModeController { } + isActive() { + return this.autoUpdateMode; + } + /** Events **/ on(event, callback) { if (!this.eventListeners[event]) this.eventListeners[event] = []; @@ -205,7 +209,6 @@ class LiveModeController { } _scheduleTimelineReload(eventData) { - // Add event to pending events this.pendingEvents.push(eventData); if (this.firstEventTime === null) { diff --git a/src/main/resources/static/js/SettingsMenu.js b/src/main/resources/static/js/SettingsMenu.js index 342fc344b..14607b5cd 100644 --- a/src/main/resources/static/js/SettingsMenu.js +++ b/src/main/resources/static/js/SettingsMenu.js @@ -54,6 +54,13 @@ class SettingsMenu { ${t('map.settings.dialog.interface.datepicker-visible')} +
+ + +
@@ -61,22 +68,17 @@ class SettingsMenu { } init() { - // Set up close button const closeBtn = this.menu.querySelector('.close-settings-btn'); if (closeBtn) { closeBtn.addEventListener('click', () => this.close()); } - // Set up overlay click to close this.overlay.addEventListener('click', () => this.close()); - // Prevent menu clicks from closing this.menu.addEventListener('click', (e) => e.stopPropagation()); - // Initialize checkboxes with current state this.initializeCheckboxes(); - // Set up checkbox event listeners this.setupCheckboxListeners(); } @@ -92,6 +94,11 @@ class SettingsMenu { if (datepickerCheckbox) { datepickerCheckbox.checked = !document.body.classList.contains('datepicker-hidden'); } + + const showAvatarsCheckbox = this.menu.querySelector('#show-avatars-checkbox'); + if (showAvatarsCheckbox) { + showAvatarsCheckbox.checked = localStorage.getItem('showAvatars') !== 'false'; // default true + } // Load and apply saved settings this.loadSettings(); @@ -129,6 +136,13 @@ class SettingsMenu { this.updateAggregate(e.target.checked); }); } + + const showAvatarsCheckbox = this.menu.querySelector('#show-avatars-checkbox'); + if (showAvatarsCheckbox) { + showAvatarsCheckbox.addEventListener('change', (e) => { + this.updateShowAvatars(e.target.checked); + }); + } } toggleTimeline(visible) { @@ -210,7 +224,11 @@ class SettingsMenu { btn.title = t('datepicker.state.hide.title'); } } - + updateShowAvatars(visible) { + localStorage.setItem('showAvatars', visible); + this.dispatchSettingsChange('showAvatars', visible); + } + open() { if (this.isVisible) return; @@ -247,7 +265,8 @@ class SettingsMenu { viewMode: localStorage.getItem('view-mode') || 'LINEAR', timelineHidden: localStorage.getItem('timelineHidden') === 'true', datepickerHidden: localStorage.getItem('datepickerHidden') === 'true', - timelineControlsHidden: localStorage.getItem('timelineControlsHidden') === 'true' + timelineControlsHidden: localStorage.getItem('timelineControlsHidden') === 'true', + showAvatars: localStorage.getItem('showAvatars') !== 'false' }; this.applySettings(settings); @@ -266,7 +285,12 @@ class SettingsMenu { if (aggregateToggle) { aggregateToggle.checked = settings.aggregate; } - + + const showAvatarsCheckbox = this.menu.querySelector('#show-avatars-checkbox'); + if (showAvatarsCheckbox) { + showAvatarsCheckbox.checked = settings.showAvatars; + } + // Apply timeline visibility if (settings.timelineHidden) { document.body.classList.add('timeline-hidden'); diff --git a/src/main/resources/static/js/auto-complete.js b/src/main/resources/static/js/auto-complete.js new file mode 100644 index 000000000..61800bfd8 --- /dev/null +++ b/src/main/resources/static/js/auto-complete.js @@ -0,0 +1,192 @@ +class Autocomplete { + /** + * @param {HTMLInputElement} input + * @param {Object} options + * @param {string} options.url - JSON endpoint (GET, ?query= parameter) + * @param {number} options.minLength - minimum characters before fetching + * @param {number} options.delay - debounce delay in ms + * @param {string|HTMLElement} options.container - suggestions container (id, selector ref, or element) + * @param {function(string): string} options.renderItem - returns HTML for a single suggestion + * @param {function(string): void} options.onSelect - called when a suggestion is chosen + */ + constructor(input, options = {}) { + this.input = input; + + // --- URL --- + this.url = options.url || input.dataset.autocompleteUrl || ''; + + // --- minLength --- + if (options.minLength !== undefined) { + this.minLength = options.minLength; + } else { + const ml = input.dataset.autocompleteMinLength; + this.minLength = ml ? parseInt(ml, 10) : 2; + } + + // --- delay --- + if (options.delay !== undefined) { + this.delay = options.delay; + } else { + const d = input.dataset.autocompleteDelay; + this.delay = d ? parseInt(d, 10) : 300; + } + + // --- renderItem (default returns a simple
) --- + this.renderItem = options.renderItem || (item => `
${item}
`); + + // --- onSelect (default writes into the input) --- + this.onSelect = options.onSelect || (item => { + this.input.value = item; + }); + + this.tagMode = options.tagMode || input.dataset.autocompleteTagMode === 'true'; + + // --- container resolution --- + const containerOpt = options.container || input.dataset.autocompleteContainer; + if (containerOpt) { + if (typeof containerOpt === 'string') { + this.container = document.querySelector(containerOpt); + } else if (containerOpt instanceof HTMLElement) { + this.container = containerOpt; + } + } + if (!this.container) { + // fallback – try next sibling with class 'suggestions-dropdown' + const sibling = input.nextElementSibling; + if (sibling && sibling.classList.contains('suggestions-dropdown')) { + this.container = sibling; + } else { + // create one + const dd = document.createElement('div'); + dd.className = 'suggestions-dropdown'; + input.parentNode.insertBefore(dd, input.nextElementSibling); + this.container = dd; + } + } + + this.timer = null; + this.activeIndex = -1; + this.loading = false; + + this.input.addEventListener('input', () => this.handleInput()); + this.input.addEventListener('keydown', (e) => this.handleKeyDown(e)); + this.input.addEventListener('blur', () => { + setTimeout(() => this.hideSuggestions(), 200); + }); + } + + handleInput() { + const query = this.input.value.trim(); + if (query.length < this.minLength) { + this.hideSuggestions(); + return; + } + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(async () => { + await this.fetchSuggestions(query); + }, this.delay); + } + + async fetchSuggestions(query) { + this.loading = true; + try { + const resp = await fetch(`${this.url}?query=${encodeURIComponent(query)}`); + if (!resp.ok) { + this.hideSuggestions(); + return; + } + const data = await resp.json(); // assume array of strings + this.renderSuggestions(data); + } catch (e) { + this.hideSuggestions(); + } finally { + this.loading = false; + } + } + + renderSuggestions(items) { + this.container.innerHTML = ''; + if (!items || items.length === 0) { + this.hideSuggestions(); + return; + } + const fragment = document.createDocumentFragment(); + items.forEach((item, idx) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.renderItem(item); + const node = wrapper.firstElementChild; + if (node) { + node.classList.add('suggestion-item'); + node.dataset.autocompleteValue = item; // store original suggestion string + node.addEventListener('mousedown', (e) => { + e.preventDefault(); // prevent blur before select + this.selectSuggestion(item); + }); + fragment.appendChild(node); + } + }); + this.container.appendChild(fragment); + this.activeIndex = -1; + } + + selectSuggestion(item) { + this.onSelect(item); + this.hideSuggestions(); + } + + hideSuggestions() { + if (this.container) { + this.container.innerHTML = ''; + } + } + + handleKeyDown(event) { + const items = this.container ? this.container.querySelectorAll('.suggestion-item') : []; + + // Tag‑mode: commit raw input on Enter/Space when no active item is highlighted + if ((event.key === 'Enter' || event.key === ' ') && this.tagMode) { + const value = this.input.value.trim(); + if (value !== '' && (this.activeIndex === -1 || items.length === 0)) { + event.preventDefault(); + this.selectSuggestion(value); + return; + } + } + + // Don't react to arrows if there are no suggestions + if (items.length === 0) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.activeIndex = Math.min(this.activeIndex + 1, items.length - 1); + this.updateActiveItem(items); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.activeIndex = Math.max(this.activeIndex - 1, 0); + this.updateActiveItem(items); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (this.activeIndex >= 0 && this.activeIndex < items.length) { + const activeNode = items[this.activeIndex]; + const value = activeNode ? activeNode.dataset.autocompleteValue : undefined; + if (value) this.selectSuggestion(value); + } + } else if (event.key === 'Escape') { + if (items.length > 0) { + event.preventDefault(); + event.stopPropagation(); + this.hideSuggestions(); + } + } + } + + updateActiveItem(items) { + items.forEach((el, idx) => { + if (idx === this.activeIndex) { + el.classList.add('active'); + } else { + el.classList.remove('active'); + } + }); + } +} diff --git a/src/main/resources/static/js/datetime-picker.js b/src/main/resources/static/js/datetime-picker.js index 25fc42462..e90192cf8 100644 --- a/src/main/resources/static/js/datetime-picker.js +++ b/src/main/resources/static/js/datetime-picker.js @@ -1,7 +1,11 @@ /** * DateTimePicker - A custom datetime picker component - * Provides calendar, year selection, and time selection functionality - * Updated to work with separate date and time inputs + * Provides calendar, year selection, and (optionally) time selection. + * + * Modes: + * - Full mode: container has both .date-input and .time-input + * - Date-only: container has only .date-input + * (time is always 00:00:00 in selectedDate and getValue) */ class DateTimePicker { /** @@ -20,7 +24,8 @@ class DateTimePicker { minDate: options.minDate || null, maxDate: options.maxDate || null, onValidate: options.onValidate || null, - locale: options.locale || navigator.language + locale: options.locale || navigator.language, + popupPlacement: options.popupPlacement || 'auto' // 'top' | 'bottom' | 'auto' }; this.element = element; @@ -28,6 +33,9 @@ class DateTimePicker { this.timeInput = element.querySelector('.time-input'); this.triggerButton = element.querySelector('.picker-trigger'); + // Date-only mode when no time input exists + this.dateOnly = !this.timeInput; + // Create popup structure if it doesn't exist if (!element.querySelector('.picker-popup')) { this.createPopupStructure(); @@ -37,7 +45,7 @@ class DateTimePicker { this.calendarSection = element.querySelector('.calendar-section'); this.yearScroll = element.querySelector('.year-scroll'); this.timeScroll = element.querySelector('.time-scroll'); - + this._listeners = { change: [] }; this.currentDate = new Date(); this.selectedDate = null; @@ -54,6 +62,7 @@ class DateTimePicker { const pickerContainer = document.createElement('div'); pickerContainer.className = 'picker-container'; + if (this.dateOnly) pickerContainer.classList.add('date-only'); // Calendar section const calendarSection = document.createElement('div'); @@ -105,18 +114,20 @@ class DateTimePicker { yearScroll.appendChild(yearList); - // Time scroll section - const timeScroll = document.createElement('div'); - timeScroll.className = 'time-scroll'; + pickerContainer.appendChild(calendarSection); + pickerContainer.appendChild(yearScroll); - const timeList = document.createElement('div'); - timeList.className = 'time-list'; + // Time scroll section — only in full mode + if (!this.dateOnly) { + const timeScroll = document.createElement('div'); + timeScroll.className = 'time-scroll'; - timeScroll.appendChild(timeList); + const timeList = document.createElement('div'); + timeList.className = 'time-list'; - pickerContainer.appendChild(calendarSection); - pickerContainer.appendChild(yearScroll); - pickerContainer.appendChild(timeScroll); + timeScroll.appendChild(timeList); + pickerContainer.appendChild(timeScroll); + } popup.appendChild(pickerContainer); this.element.appendChild(popup); @@ -129,7 +140,7 @@ class DateTimePicker { this.setupEventListeners(); this.renderCalendar(); this.renderYearList(); - this.renderTimeList(); + if (!this.dateOnly) this.renderTimeList(); this.updateFromInputs(); // Preselect current date and closest time if no date is selected @@ -157,12 +168,14 @@ class DateTimePicker { } }); - this.timeInput.addEventListener('change', () => { - this.updateFromInputs(); - if (this.options.onValidate) { - this.options.onValidate(); - } - }); + if (this.timeInput) { + this.timeInput.addEventListener('change', () => { + this.updateFromInputs(); + if (this.options.onValidate) { + this.options.onValidate(); + } + }); + } // Calendar navigation const prevMonthBtn = this.element.querySelector('.prev-month'); @@ -206,21 +219,26 @@ class DateTimePicker { } /** - * Select today's date and closest time + * Select today's date (and, in full mode, closest 15-min time). + * In date-only mode, time is pinned to 00:00:00. */ selectToday() { const now = new Date(); this.currentDate = new Date(now); this.selectedDate = new Date(now); - // Round to nearest 15 minutes - const minutes = Math.round(now.getMinutes() / 15) * 15; - this.selectedDate.setMinutes(minutes, 0, 0); + if (this.dateOnly) { + this.selectedDate.setHours(0, 0, 0, 0); + } else { + // Round to nearest 15 minutes + const minutes = Math.round(now.getMinutes() / 15) * 15; + this.selectedDate.setMinutes(minutes, 0, 0); + } this.updateInputs(); this.renderCalendar(); this.renderYearList(); - this.highlightSelectedTime(); + if (!this.dateOnly) this.highlightSelectedTime(); } /** @@ -254,7 +272,6 @@ class DateTimePicker { // Render days grid daysGrid.innerHTML = ''; const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1); - const lastDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0); const startDate = new Date(firstDay); startDate.setDate(startDate.getDate() - firstDay.getDay()); @@ -326,31 +343,25 @@ class DateTimePicker { * @param {number} year - The year to change to */ changeYear(year) { - // Store the current day of month const currentDay = this.selectedDate ? this.selectedDate.getDate() : 1; const currentMonth = this.selectedDate ? this.selectedDate.getMonth() : this.currentDate.getMonth(); - // Update current date to the new year this.currentDate.setFullYear(year); - // Try to maintain the same month and day let newDate = new Date(year, currentMonth, currentDay); - // If the day is invalid for the new month/year, find the next valid day if (newDate.getMonth() !== currentMonth || this.isDateDisabled(newDate)) { - // Try the last day of the month if current day is invalid const lastDayOfMonth = new Date(year, currentMonth + 1, 0).getDate(); newDate = new Date(year, currentMonth, Math.min(currentDay, lastDayOfMonth)); - // If still disabled, find the next available day if (this.isDateDisabled(newDate)) { newDate = this.findNextValidDate(newDate); } } - // Update selected date if we have one if (this.selectedDate) { this.selectedDate.setFullYear(year, currentMonth, newDate.getDate()); + if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0); this.updateInputs(); } @@ -360,12 +371,10 @@ class DateTimePicker { /** * Find the next valid date starting from a given date - * @param {Date} startDate - The date to start searching from - * @returns {Date} The next valid date */ findNextValidDate(startDate) { let currentDate = new Date(startDate); - const maxAttempts = 31; // Don't search more than a month ahead + const maxAttempts = 31; for (let i = 0; i < maxAttempts; i++) { currentDate.setDate(currentDate.getDate() + 1); @@ -374,15 +383,16 @@ class DateTimePicker { } } - // If no valid date found in the next month, return the first day of the month return new Date(startDate.getFullYear(), startDate.getMonth(), 1); } /** - * Render the time selection list + * Render the time selection list (full mode only) */ renderTimeList() { + if (this.dateOnly) return; const timeList = this.element.querySelector('.time-list'); + if (!timeList) return; timeList.innerHTML = ''; for (let hour = 0; hour < 24; hour++) { @@ -408,9 +418,6 @@ class DateTimePicker { /** * Format time according to the specified format - * @param {number} hour - Hour value (0-23) - * @param {number} minute - Minute value (0-59) - * @returns {string} Formatted time string */ formatTime(hour, minute) { if (this.options.timeFormat === '12h') { @@ -421,26 +428,53 @@ class DateTimePicker { return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; } } + /** + * Update the picker's displayed date without firing the 'change' event. + * Useful for transient sync (e.g., hover tracking) that shouldn't be + * treated as a user selection. + * @param {Date} date + */ + setDateSilent(date) { + if (!(date instanceof Date) || isNaN(date.getTime())) return; + + this.selectedDate = new Date(date); + if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0); + this.currentDate = new Date(this.selectedDate); + + // Update inputs without triggering change on them either + const y = this.selectedDate.getFullYear(); + const m = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0'); + const d = this.selectedDate.getDate().toString().padStart(2, '0'); + this.dateInput.value = `${y}-${m}-${d}`; + + if (this.timeInput) { + const hh = this.selectedDate.getHours().toString().padStart(2, '0'); + const mm = this.selectedDate.getMinutes().toString().padStart(2, '0'); + this.timeInput.value = `${hh}:${mm}`; + } + this.renderCalendar(); + this.renderYearList(); + if (!this.dateOnly) this.highlightSelectedTime(); + } /** - * Select a specific date - * @param {Date} date - The date to select + * Select a specific date. In date-only mode the time is pinned to 00:00:00. */ selectDate(date) { if (!this.selectedDate) { this.selectedDate = new Date(); } this.selectedDate.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0); this.updateInputs(); this.renderCalendar(); } /** - * Select a specific time - * @param {number} hour - Hour value (0-23) - * @param {number} minute - Minute value (0-59) + * Select a specific time (no-op in date-only mode) */ selectTime(hour, minute) { + if (this.dateOnly) return; if (!this.selectedDate) { this.selectedDate = new Date(); } @@ -450,9 +484,10 @@ class DateTimePicker { } /** - * Highlight the currently selected time in the time list + * Highlight the currently selected time in the time list (full mode only) */ highlightSelectedTime() { + if (this.dateOnly) return; this.element.querySelectorAll('.time-item').forEach(item => { item.classList.remove('selected'); if (this.selectedDate && @@ -463,40 +498,48 @@ class DateTimePicker { }); } - /** - * Update the date and time inputs with the selected date/time - */ updateInputs() { - if (this.selectedDate) { - // Format date as YYYY-MM-DD - const year = this.selectedDate.getFullYear(); - const month = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0'); - const day = this.selectedDate.getDate().toString().padStart(2, '0'); - this.dateInput.value = `${year}-${month}-${day}`; + if (!this.selectedDate) return; + + const year = this.selectedDate.getFullYear(); + const month = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0'); + const day = this.selectedDate.getDate().toString().padStart(2, '0'); + this.dateInput.value = `${year}-${month}-${day}`; + this.dateInput.dispatchEvent(new Event('change')); - // Format time as HH:MM + if (this.timeInput) { const hours = this.selectedDate.getHours().toString().padStart(2, '0'); const minutes = this.selectedDate.getMinutes().toString().padStart(2, '0'); this.timeInput.value = `${hours}:${minutes}`; - - // Trigger change events - this.dateInput.dispatchEvent(new Event('change')); this.timeInput.dispatchEvent(new Event('change')); } + + this._emit('change'); } /** * Update the picker state from the input values */ updateFromInputs() { - if (this.dateInput.value && this.timeInput.value) { + if (!this.dateInput.value) return; + + if (this.dateOnly) { + // Parse YYYY-MM-DD as local midnight + const [y, m, d] = this.dateInput.value.split('-').map(Number); + this.selectedDate = new Date(y, m - 1, d, 0, 0, 0, 0); + } else if (this.timeInput && this.timeInput.value) { const dateTimeString = `${this.dateInput.value}T${this.timeInput.value}`; this.selectedDate = new Date(dateTimeString); - this.currentDate = new Date(this.selectedDate); - this.renderCalendar(); - this.renderYearList(); - this.highlightSelectedTime(); + } else { + return; } + + this.currentDate = new Date(this.selectedDate); + this.renderCalendar(); + this.renderYearList(); + if (!this.dateOnly) this.highlightSelectedTime(); + + this._emit('change'); } /** @@ -511,17 +554,40 @@ class DateTimePicker { } } - /** - * Open the popup - */ openPopup() { this.popup.style.display = 'block'; + this.applyPopupPlacement(); if (this.selectedDate) { this.scrollToSelectedYear(); - this.scrollToSelectedTime(); + if (!this.dateOnly) this.scrollToSelectedTime(); } } + /** + * Decide whether the popup should render above or below the trigger. + * - 'top' / 'bottom': explicit, always used + * - 'auto': flip to top only if there isn't enough room below + */ + applyPopupPlacement() { + const mode = this.options.popupPlacement; + let placeOnTop; + + if (mode === 'top') placeOnTop = true; + else if (mode === 'bottom') placeOnTop = false; + else { + // auto — measure available space + const triggerRect = this.triggerButton.getBoundingClientRect(); + const popupHeight = this.popup.offsetHeight; + const viewportH = window.innerHeight; + const spaceBelow = viewportH - triggerRect.bottom; + const spaceAbove = triggerRect.top; + // Prefer bottom, but flip if it would clip and top has more room + placeOnTop = spaceBelow < popupHeight && spaceAbove > spaceBelow; + } + + this.popup.classList.toggle('placement-top', placeOnTop); + this.popup.classList.toggle('placement-bottom', !placeOnTop); + } /** * Close the popup */ @@ -540,9 +606,10 @@ class DateTimePicker { } /** - * Scroll to the selected time in the time list + * Scroll to the selected time in the time list (full mode only) */ scrollToSelectedTime() { + if (this.dateOnly) return; const selectedTime = this.element.querySelector('.time-item.selected'); if (selectedTime) { selectedTime.scrollIntoView({ block: 'center' }); @@ -551,52 +618,86 @@ class DateTimePicker { /** * Check if two dates are the same day - * @param {Date} date1 - First date - * @param {Date} date2 - Second date - * @returns {boolean} True if same day */ isSameDay(date1, date2) { return date1.getFullYear() === date2.getFullYear() && - date1.getMonth() === date2.getMonth() && - date1.getDate() === date2.getDate(); + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate(); } /** * Check if a date is disabled - * @param {Date} date - Date to check - * @returns {boolean} True if disabled */ isDateDisabled(date) { if (this.options.minDate && date < this.options.minDate) { return true; } - if (this.options.maxDate && date > this.options.maxDate) { - return true; - } - return false; + return this.options.maxDate && date > this.options.maxDate; + } /** - * Get the current value as ISO string - * @returns {string} Current datetime value + * Get the current value as ISO string. + * Date-only mode returns 'YYYY-MM-DDT00:00:00'. */ getValue() { - if (this.dateInput.value && this.timeInput.value) { + if (!this.dateInput.value) return ''; + if (this.dateOnly) { + return `${this.dateInput.value}T00:00:00`; + } + if (this.timeInput && this.timeInput.value) { return `${this.dateInput.value}T${this.timeInput.value}`; } return ''; } /** - * Set the value from ISO string - * @param {string} value - ISO datetime string + * Set the value from ISO string. In date-only mode the time portion + * is ignored and the date is stored with time pinned to 00:00:00. */ setValue(value) { - if (value) { - const date = new Date(value); - this.dateInput.value = value.split('T')[0]; - this.timeInput.value = value.split('T')[1]?.substring(0, 5) || ''; - this.updateFromInputs(); + if (!value) return; + const [datePart, timePart] = value.split('T'); + this.dateInput.value = datePart; + if (!this.dateOnly && this.timeInput) { + this.timeInput.value = timePart ? timePart.substring(0, 5) : ''; + } + this.updateFromInputs(); + } + + /** + * Subscribe to a picker event. + * @param {string} eventName - Currently supported: 'change' + * @param {Function} handler - Called with (value, selectedDate, picker) + * @returns {Function} Unsubscribe function + */ + on(eventName, handler) { + if (!this._listeners[eventName]) this._listeners[eventName] = []; + this._listeners[eventName].push(handler); + return () => this.off(eventName, handler); + } + + /** + * Unsubscribe a previously registered handler. + */ + off(eventName, handler) { + const arr = this._listeners[eventName]; + if (!arr) return; + const i = arr.indexOf(handler); + if (i >= 0) arr.splice(i, 1); + } + + /** + * Internal: invoke all handlers for an event. + */ + _emit(eventName) { + const arr = this._listeners[eventName]; + if (!arr || !arr.length) return; + const value = this.getValue(); + const selectedDate = this.selectedDate ? new Date(this.selectedDate) : null; + for (const h of arr) { + try { h(value, selectedDate, this); } + catch (err) { console.error(`[DateTimePicker] ${eventName} handler threw:`, err); } } } -} +} \ No newline at end of file diff --git a/src/main/resources/static/js/gps-data-manager.js b/src/main/resources/static/js/gps-data-manager.js index 8bd7108a2..85887e27f 100644 --- a/src/main/resources/static/js/gps-data-manager.js +++ b/src/main/resources/static/js/gps-data-manager.js @@ -22,6 +22,8 @@ class GpsDataManager { this.bounds = null; this._dataCache = {}; this.lastLocation = null; + this.activityWeights = [0]; + this.totalActivity = 0; } async loadFixed(onProgress) { @@ -50,7 +52,6 @@ class GpsDataManager { } async load(startUTC, endUTC, onProgress) { - // 1. Check if we already have this data in memory if (this.loadingState === 'complete' && startUTC >= this.minTimestamp && endUTC <= this.maxTimestamp) { @@ -145,7 +146,7 @@ class GpsDataManager { this.loadingState = 'bundling'; await this._generateBundledPath(onProgress); - + this._computeActivityWeights(); this.loadingState = 'complete'; if (onProgress) onProgress(this.totalExpected, this.totalExpected, 'complete'); if (this.config.map.visitsUrl) { @@ -182,69 +183,61 @@ class GpsDataManager { } let payload; + const thresholdSec = 300; // 5 minutes. if (mode === 'bundled') { const stride = 20; - const usedLength = this.cleanedCursor * 5; + const strideFloats = 5; // 20 bytes / 4 bytes per float + const timeIndex = isAggregate ? 4 : 2; // Byte offset 16/4 or 8/4 + const usedLength = this.cleanedCursor * strideFloats; const trimmed = this.snappedBuffer.subarray(0, usedLength); + + const { length, startIndices } = this._getBinaryIndices( + trimmed, this.cleanedCursor, strideFloats, timeIndex, thresholdSec + ); + payload = { - length: 1, - startIndices: new Uint32Array([0, this.cleanedCursor]), + length: length, + startIndices: startIndices, attributes: { - getPath: { - value: trimmed, - size: 2, - stride: stride, - offset: 0 - }, - getTimestamps: { - value: trimmed, - size: 1, - stride: stride, - offset: isAggregate ? 16 : 8 - } + getPath: { value: trimmed, size: 2, stride: stride, offset: 0 }, + getTimestamps: { value: trimmed, size: 1, stride: stride, offset: isAggregate ? 16 : 8 } } }; } else if (mode === 'cleaned') { - const usedLength = this.cleanedCursor * 6; + const stride = 24; + const strideFloats = 6; + const timeIndex = isAggregate ? 5 : 3; + const usedLength = this.cleanedCursor * strideFloats; const trimmed = this.cleanedBuffer.subarray(0, usedLength); + + const { length, startIndices } = this._getBinaryIndices( + trimmed, this.cleanedCursor, strideFloats, timeIndex, thresholdSec + ); + payload = { - length: 1, - startIndices: new Uint32Array([0, this.cleanedCursor]), + length: length, + startIndices: startIndices, attributes: { - getPath: { - value: trimmed, - size: 3, - stride: 24, - offset: 0 - }, - getTimestamps: { - value: trimmed, - size: 1, - stride: 24, - offset: isAggregate ? 20 : 12 - } + getPath: { value: trimmed, size: 3, stride: stride, offset: 0 }, + getTimestamps: { value: trimmed, size: 1, stride: stride, offset: isAggregate ? 20 : 12 } } }; } else if (mode === 'raw') { - const usedLength = this.cursor * 6; + const stride = 24; + const strideFloats = 6; + const timeIndex = isAggregate ? 5 : 3; + const usedLength = this.cursor * strideFloats; const trimmed = this.buffer.subarray(0, usedLength); + const { length, startIndices } = this._getBinaryIndices( + trimmed, this.cursor, strideFloats, timeIndex, thresholdSec + ); payload = { - length: 1, - startIndices: new Uint32Array([0, this.cursor]), + length: length, + startIndices: startIndices, attributes: { - getPath: { - value: trimmed, - size: 3, - stride: 24, - offset: 0 - }, - getTimestamps: { - value: trimmed, - size: 1, - stride: 24, - offset: isAggregate ? 20 : 12 - } + getPath: { value: trimmed, size: 3, stride: stride, offset: 0 }, + getTimestamps: { value: trimmed, size: 1, stride: stride, offset: isAggregate ? 20 : 12 } } }; } @@ -257,6 +250,37 @@ class GpsDataManager { return payload; } + _getBinaryIndices(buffer, pointCount, strideFloats, timeIndex, thresholdSec) { + // If continuous is true, or no points, return single path + if (this.config.continuous || pointCount === 0) { + return { + length: 1, + startIndices: new Uint32Array([0, pointCount]) + }; + } + + const indices = [0]; + + // Scan buffer for time gaps + for (let i = 0; i < pointCount - 1; i++) { + const t1 = buffer[i * strideFloats + timeIndex]; + const t2 = buffer[(i + 1) * strideFloats + timeIndex]; + + // If the gap exceeds the threshold, break the line at i+1 + if (Math.abs(t2 - t1) > thresholdSec) { + indices.push(i + 1); + } + } + + // Always push the final vertex count as required by deck.gl + indices.push(pointCount); + + return { + length: indices.length - 1, + startIndices: new Uint32Array(indices) + }; + } + async _streamPoints(onProgress, signal) { if (this.config.map.streamUrl === undefined) { const midTimestamp = this.minTimestamp != null && this.maxTimestamp != null @@ -450,8 +474,159 @@ class GpsDataManager { if (onProgress) onProgress(this.cleanedCursor, this.cleanedCursor, 'bundling'); } + _computeActivityWeights() { + if (!this.visits || this.visits.length === 0) { + this.activityWeights = new Float64Array([0]); + this.totalActivity = 0; + return; + } + + const visitWeight = 0.05; + const tripWeight = 1.0; + const bufferFactor = 0.30; + const maxBuffer = 600; + + // === CACHE SORTED RANGES (huge win) === + const currentHash = this._computeVisitsHash(); + if (!this._sortedVisitRanges || this._visitsHash !== currentHash) { + this._sortedVisitRanges = this.visits + .flatMap(p => p.activeRanges || []) + .sort((a, b) => a.start - b.start); + this._visitsHash = currentHash; + } + + const ranges = this._sortedVisitRanges; + const n = this.cleanedCursor; + + const weights = new Float64Array(n); + weights[0] = 0; + + let cumulative = 0; + let rangeIdx = 0; + let lastPrevRange = null; + + for (let i = 1; i < n; i++) { + const tsPrev = this.cleanedBuffer[(i - 1) * 6 + 3]; + const tsCurr = this.cleanedBuffer[i * 6 + 3]; + const dt = tsCurr - tsPrev; + + if (dt <= 0) { + weights[i] = cumulative; + continue; + } + + while (rangeIdx < ranges.length && ranges[rangeIdx].end < tsCurr) { + lastPrevRange = ranges[rangeIdx]; + rangeIdx++; + } + + let factor = tripWeight; + let inVisit = false; + + for (let j = rangeIdx; j < ranges.length; j++) { + const r = ranges[j]; + if (r.start > tsCurr) break; + if (tsCurr >= r.start && tsCurr <= r.end) { + inVisit = true; + factor = visitWeight; + break; + } + } + + if (!inVisit) { + let prevEnd = tsCurr - 3600; + let nextStart = tsCurr + 3600; + + if (lastPrevRange && lastPrevRange.end < tsCurr) { + prevEnd = lastPrevRange.end; + } else { + for (let j = rangeIdx - 1; j >= 0; j--) { + if (ranges[j].end < tsCurr) { + prevEnd = ranges[j].end; + lastPrevRange = ranges[j]; + break; + } + } + } + + for (let j = rangeIdx; j < ranges.length; j++) { + if (ranges[j].start > tsCurr) { + nextStart = ranges[j].start; + break; + } + } + + const tripDuration = nextStart - prevEnd; + const buffer = Math.min(tripDuration * bufferFactor, maxBuffer); + + const distToNext = nextStart - tsCurr; + const distToPrev = tsCurr - prevEnd; + + if (distToNext >= 0 && distToNext <= buffer) { + const progress = 1 - (distToNext / buffer); + const smooth = progress * progress * (3 - 2 * progress); + factor = tripWeight - smooth * (tripWeight - visitWeight); + } else if (distToPrev >= 0 && distToPrev <= buffer) { + const progress = distToPrev / buffer; + const smooth = progress * progress * (3 - 2 * progress); + factor = visitWeight + smooth * (tripWeight - visitWeight); + } + } + + cumulative += dt * factor; + weights[i] = cumulative; + } + + this.activityWeights = weights; + this.totalActivity = cumulative; + } + + _computeVisitsHash() { + let hash = 17; + for (const visit of this.visits) { + for (const r of visit.activeRanges || []) { + hash = (hash * 31 + Math.imul(Math.floor(r.start), 0x85ebca6b)) | 0; + hash = (hash * 31 + Math.imul(Math.floor(r.end), 0xc2b2ae35)) | 0; + } + } + return hash >>> 0; + } + + getDisplayTimestamp(linearProgress, useWarp = false, aggregate = false) { + if (aggregate || !useWarp || !this.activityWeights?.length) { + const total = this.maxTimestamp - this.minTimestamp; + return this.minTimestamp + linearProgress * total; + } + + const target = linearProgress * this.totalActivity; + const weights = this.activityWeights; + let low = 0; + let high = weights.length - 1; + + while (low <= high) { + const mid = (low + high) >> 1; + if (weights[mid] <= target) low = mid + 1; + else high = mid - 1; + } + + const i = Math.max(0, low - 1); + if (i >= weights.length - 1) { + return this.cleanedBuffer[(weights.length - 1) * 6 + 3]; + } + + const w0 = weights[i]; + const w1 = weights[i + 1]; + const t0 = this.cleanedBuffer[i * 6 + 3]; + const t1 = this.cleanedBuffer[(i + 1) * 6 + 3]; + + if (w1 === w0) return t0; + return t0 + ((target - w0) / (w1 - w0)) * (t1 - t0); + } + _hexToRgb(hex) { const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); return [r, g, b]; } + + } \ No newline at end of file diff --git a/src/main/resources/static/js/gpx-downloader.js b/src/main/resources/static/js/gpx-downloader.js index aef0ff153..0bdcaf595 100644 --- a/src/main/resources/static/js/gpx-downloader.js +++ b/src/main/resources/static/js/gpx-downloader.js @@ -3,7 +3,7 @@ class GpxDownloader { this.isDownloading = false; } - async downloadGpx(startDate, endDate, buttonElement, relevantData = false) { + async downloadGpx(deviceId, startDate, endDate, buttonElement, relevantData = false) { if (this.isDownloading) { return; } @@ -14,6 +14,7 @@ class GpxDownloader { try { const timezone = getUserTimezone(); const params = new URLSearchParams({ + deviceId: deviceId, startDate: startDate, endDate: endDate, timezone: timezone, diff --git a/src/main/resources/static/js/map-controls.js b/src/main/resources/static/js/map-controls.js index 414c74acd..bab755fd9 100644 --- a/src/main/resources/static/js/map-controls.js +++ b/src/main/resources/static/js/map-controls.js @@ -13,6 +13,10 @@ class MapControls {
+ +
`).join(''); + + list.querySelectorAll('[data-undo-id]').forEach(btn => { + btn.addEventListener('click', () => undoAction(btn.dataset.undoId)); + }); +} + +function fmtAgo(iso) { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 5000) return 'just now'; + if (ms < 60000) return Math.floor(ms / 1000) + 's ago'; + return Math.floor(ms / 60000) + 'm ago'; +} + +setInterval(() => { + if (!History.length) return; + const list = document.getElementById('histList'); + if (!list) return; + + const metas = list.querySelectorAll('.history-meta'); + const reversed = History.slice().reverse(); + metas.forEach((el, i) => { + const a = reversed[i]; + if (!a) return; + el.textContent = `#${String(a.seq).padStart(3, '0')} · ${fmtAgo(a.at)}`; + }); +}, 15000);; +document.getElementById('histClear').addEventListener('click', clearHistory); + +/* ============================================================ + MAPLIBRE + ============================================================ */ +const map = new maplibregl.Map({ + container: 'map', + style: getMapStyleValue(window.reittiCustomMapStyles.find(v => v.id === window.reittiActiveMapStyleId)), + center: [window.userSettings.homeLongitude, window.userSettings.homeLatitude], zoom: 13.7 +}); + +function getMapStyleValue(mapStyle) { + if (mapStyle?.mapType === 'vector') { + if (mapStyle?.styleInputType === 'url') { + return mapStyle?.styleUrl; + } else if (mapStyle?.styleInputType === 'json') { + return MapRenderer._cloneStaticStyleDefinition(mapStyle.styleInput); + } else { + throw new Error('Invalid vector style input type'); + } + } else if (mapStyle?.mapType === 'raster') { + if (mapStyle?.rasterSourceInputType === 'json-url') { + const tileJsonUrl = mapStyle?.dataSource?.tileJsonUrl; + if (!tileJsonUrl) { + throw new Error('Raster style missing tileJsonUrl'); + } + return { + version: 8, + name: mapStyle.label || 'Raster', + sources: { + 'raster-tiles': { + type: 'raster', + url: tileJsonUrl, + tileSize: mapStyle?.dataSource?.tileSize || 256, + attribution: mapStyle?.dataSource?.attribution || '' + } + }, + layers: [{ + id: 'raster-layer', + type: 'raster', + source: 'raster-tiles', + minzoom: mapStyle?.dataSource?.minzoom || 0, + maxzoom: mapStyle?.dataSource?.maxzoom || 22 + }] + }; + } else if (mapStyle?.rasterSourceInputType === 'url-template') { + // Return a complete raster style object + const tileUrl = mapStyle?.dataSource?.tileUrlTemplate; + if (!tileUrl) { + throw new Error('Raster style missing tile URL template'); + } + return { + version: 8, + name: mapStyle.label || 'Raster', + sources: { + 'raster-tiles': { + type: 'raster', + tiles: [tileUrl], + tileSize: mapStyle?.dataSource?.tileSize || 256, + attribution: mapStyle?.dataSource?.attribution || '' + } + }, + layers: [{ + id: 'raster-layer', + type: 'raster', + source: 'raster-tiles', + minzoom: mapStyle?.dataSource?.minzoom || 0, + maxzoom: mapStyle?.dataSource?.maxzoom || 22 + }] + }; + } else { + throw new Error('Invalid raster style input type'); + } + } else { + throw new Error('Invalid map type'); + } +} +map.addControl(new maplibregl.NavigationControl({showCompass: false}), 'top-left'); + +map.on('load', () => { + if (map.getLayer('hillshading')) map.removeLayer('hillshading'); + if (map.getLayer('building-3d')) map.removeLayer('building-3d'); + map.setTerrain(null); + + const mapContainer = document.getElementById('map'); + mapContainer.classList.remove('is-loading'); + mapContainer.classList.add('is-loaded'); + + map.addSource('final-line', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'final-line-bridge', type: 'line', source: 'final-line', + filter: ['==', ['get', 'bridge'], true], + paint: { + 'line-color': '#8a93a7', + 'line-width': 1.8, + 'line-dasharray': [2, 2], + 'line-opacity': [ + 'case', + ['get', 'inWindow'], 0.85, + 0.35 + ] + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + map.addLayer({ + id: 'final-line-casing', type: 'line', source: 'final-line', + filter: ['!=', ['get', 'bridge'], true], + paint: { + 'line-color': '#0a1320', + 'line-width': 3, + 'line-opacity': [ + 'case', + ['get', 'inWindow'], 0.75, + 0.25 + ] + }, + layout: {'line-cap': 'round', 'line-join': 'round'} + }); + map.addLayer({ + id: 'final-line', type: 'line', source: 'final-line', + paint: { + 'line-color': ['get', 'color'], + 'line-width': [ + 'case', + ['get', 'inWindow'], 2.5, + 1.8 + ], + 'line-opacity': [ + 'case', + ['get', 'inWindow'], 0.98, + 0.35 + ] + }, + layout: {'line-cap': 'round', 'line-join': 'round'} + }); + + map.addSource('edit-boundary', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'edit-boundary', type: 'circle', source: 'edit-boundary', + paint: { + 'circle-radius': 6, + 'circle-color': '#f2c470', + 'circle-stroke-width': 2, + 'circle-stroke-color': '#0a1320', + 'circle-opacity': 0.85 + } + }); + + map.addSource('time-labels', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'time-labels', type: 'symbol', source: 'time-labels', + layout: { + 'text-field': ['get', 'label'], 'text-size': 11, + 'text-font': ['Open Sans Semibold'], 'text-offset': [0, -1.4], + 'text-allow-overlap': false, 'text-padding': 4 + }, + paint: { + 'text-color': '#ece6d6', 'text-halo-color': '#0a1320', + 'text-halo-width': 2.5, 'text-halo-blur': 0.5 + } + }); + + map.addSource('final-pts', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'final-pts', type: 'circle', source: 'final-pts', + paint: { + 'circle-radius': ['case', ['get', 'selected'], 8, 5], + 'circle-color': ['case', ['get', 'selected'], '#f2c470', ['get', 'color']], + 'circle-stroke-width': 2, + 'circle-stroke-color': ['case', ['get', 'selected'], '#fff5d6', '#0a1320'], + 'circle-blur': ['case', ['get', 'selected'], 0.08, 0] + } + }); + + map.addSource('cand-line', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'cand-line', type: 'line', source: 'cand-line', + paint: { + 'line-color': ['get', 'color'], 'line-width': 2.2, + 'line-dasharray': [2, 2], 'line-opacity': 0.85 + } + }); + + map.addSource('cand-active', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'cand-active-casing', type: 'line', source: 'cand-active', + paint: { + 'line-color': '#0a1320', + 'line-width': 7, + 'line-opacity': 0.8 + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + map.addLayer({ + id: 'cand-active', type: 'line', source: 'cand-active', + paint: {'line-color': ['get', 'color'], 'line-width': 4, 'line-opacity': 1} + }); + + map.addSource('scrub-pt', {type: 'geojson', data: emptyFC()}); + map.addLayer({ + id: 'scrub-halo', type: 'circle', source: 'scrub-pt', + paint: { + 'circle-radius': 22, 'circle-color': '#fff', + 'circle-opacity': 0.3, 'circle-blur': 0.8 + } + }); + map.addLayer({ + id: 'scrub-pt', type: 'circle', source: 'scrub-pt', + paint: { + 'circle-radius': 8, 'circle-color': '#e07a6b', + 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff', + 'circle-pitch-alignment': 'map' + } + }); + + loadViewportData(); + setupMapInteractions(); +}); + +function buildEditBoundaryFC() { + if (FinalTimeline.length < 2) return emptyFC(); + const { tStart, tEnd } = selectableRange(); + + const features = []; + for (const t of [tStart, tEnd]) { + const p = interpolateAtT(FinalTimeline, t); + if (!p) continue; + // Don't draw if the boundary is outside the timeline's span + const first = FinalTimeline[0].t, last = FinalTimeline[FinalTimeline.length - 1].t; + if (t < first || t > last) continue; + features.push({ + type: 'Feature', + properties: { which: t === tStart ? 'start' : 'end' }, + geometry: { type: 'Point', coordinates: [p.lng, p.lat] } + }); + } + return { type: 'FeatureCollection', features }; +} + +function emptyFC() { + return {type: 'FeatureCollection', features: []}; +} + +function buildFinalLineFC() { + if (FinalTimeline.length < 2) return { type: 'FeatureCollection', features: [] }; + const { tStart: editStart, tEnd: editEnd } = selectableRange(); + + const features = []; + let coords = []; + let currentSid = null; + let currentInWindow = null; + + const flush = () => { + if (coords.length > 1) { + features.push({ + type: 'Feature', + properties: { color: colorOf(currentSid), inWindow: currentInWindow, bridge: false }, + geometry: { type: 'LineString', coordinates: coords } + }); + } + coords = []; + }; + + const isInWindow = t => t >= editStart && t <= editEnd; + + for (let i = 0; i < FinalTimeline.length; i++) { + const p = FinalTimeline[i]; + const inWindow = isInWindow(p.t); + + if (i === 0) { + currentSid = p.streamId; + currentInWindow = inWindow; + coords.push([p.lng, p.lat]); + continue; + } + + const prev = FinalTimeline[i - 1]; + const dt = p.t - prev.t; + const sameStream = p.streamId === prev.streamId; + const cfg = gapConfigFor(p.streamId); + + const continuous = sameStream && dt <= cfg.continuousUpTo; + const sameStrBridge = sameStream && !continuous && dt <= cfg.interpolateUpTo; + const crossBridge = !sameStream && dt <= cfg.continuousUpTo; + + if (continuous && inWindow === currentInWindow) { + coords.push([p.lng, p.lat]); + } else if (continuous) { + // window-edge transition — flush with shared vertex, then start new run + coords.push([p.lng, p.lat]); + flush(); + currentSid = p.streamId; + currentInWindow = inWindow; + coords.push([p.lng, p.lat]); + } else if (sameStrBridge || crossBridge) { + // emit current run, then a separate bridge feature, then start fresh + flush(); + features.push({ + type: 'Feature', + properties: { + color: colorOf(p.streamId), + inWindow: isInWindow(prev.t) && inWindow, + bridge: true + }, + geometry: { + type: 'LineString', + coordinates: [[prev.lng, prev.lat], [p.lng, p.lat]] + } + }); + currentSid = p.streamId; + currentInWindow = inWindow; + coords.push([p.lng, p.lat]); + } else { + // hard gap + flush(); + currentSid = p.streamId; + currentInWindow = inWindow; + coords.push([p.lng, p.lat]); + } + } + flush(); + + return { type: 'FeatureCollection', features }; +} + +function buildTimeLabelsFC() { + if (FinalTimeline.length < 2) return emptyFC(); + const features = []; + const stepMs = Math.max(5 * 60000, viewportDuration / 10); + for (let t = viewportStartT; t <= viewportStartT + viewportDuration; t += stepMs) { + const p = interpolateAtT(FinalTimeline, t); + if (!p) continue; + features.push({ + type: 'Feature', properties: {label: fmtClockShort(t)}, + geometry: {type: 'Point', coordinates: [p.lng, p.lat]} + }); + } + return {type: 'FeatureCollection', features}; +} + +function selectableRange() { + return { + tStart: viewportStartT - MAIN_PAD_MS, + tEnd: viewportStartT + viewportDuration + MAIN_PAD_MS + }; +} + +function buildCandidateFC() { + const arr = (SourceData.get(W.selectedDevice) || []) + .filter(p => p.t >= viewportStartT - viewportDuration * 0.1 + && p.t <= viewportStartT + viewportDuration * 1.1); + const color = DeviceSources[W.selectedDevice == null ? 'default' : W.selectedDevice].color; + if (arr.length < 2) return emptyFC(); + + const gapMs = GAP_CONFIG.device.continuousUpTo; + + const segs = []; + let seg = [[arr[0].lng, arr[0].lat]]; + for (let i = 1; i < arr.length; i++) { + if (arr[i].t - arr[i - 1].t > gapMs) { + if (seg.length > 1) segs.push(seg); + seg = []; + } + seg.push([arr[i].lng, arr[i].lat]); + } + if (seg.length > 1) segs.push(seg); + + return { + type: 'FeatureCollection', + features: segs.map(s => ({ + type: 'Feature', + properties: {color}, + geometry: {type: 'LineString', coordinates: s} + })) + }; +} + +function buildCandidateActiveFC() { + const arr = (SourceData.get(W.selectedDevice) || []).filter(p => p.t >= W.patch.tStart && p.t <= W.patch.tEnd); + const color = DeviceSources[W.selectedDevice].color; + if (arr.length < 2) return emptyFC(); + return { + type: 'FeatureCollection', features: [{ + type: 'Feature', properties: {color}, + geometry: {type: 'LineString', coordinates: arr.map(p => [p.lng, p.lat])} + }] + }; +} + +function buildFinalPointsFC() { + if (W.tool !== 'select' && W.tool !== 'boxselect') return emptyFC(); + const {tStart, tEnd} = selectableRange(); + + const features = []; + for (const p of FinalTimeline) { + if (p.t < tStart || p.t > tEnd) continue; + if (p.sourceId == null) continue; + features.push({ + type: 'Feature', + properties: {id: p.id, color: colorOf(p.streamId), selected: W.selected.has(p.id)}, + geometry: {type: 'Point', coordinates: [p.lng, p.lat]} + }); + } + return {type: 'FeatureCollection', features}; +} + +function refreshMap() { + if (!map.getSource || !map.getSource('final-line')) return; + map.getSource('final-line').setData(buildFinalLineFC()); + map.getSource('time-labels').setData(buildTimeLabelsFC()); + map.getSource('cand-line').setData(buildCandidateFC()); + map.getSource('cand-active').setData(buildCandidateActiveFC()); + map.getSource('edit-boundary').setData(buildEditBoundaryFC()); + map.getSource('final-pts').setData(buildFinalPointsFC()); +} + +function refreshAll() { + refreshMap(); + drawMainLane(); + drawDeviceLane(); + syncPatchBox(); + updateButtons(); + updateWovenCount(); + renderSelectionInfo(); +} + +/* ============================================================ + CANVASES + ============================================================ */ +const mainCanvas = document.getElementById('mainCanvas'); +const deviceCanvas = document.getElementById('deviceCanvas'); +const mctx = mainCanvas.getContext('2d'); +const dctx = deviceCanvas.getContext('2d'); +const patchBox = document.getElementById('patchBox'); +const playhead = document.getElementById('playhead'); +const boxOverlay = document.getElementById('boxSelectOverlay'); +const btnCopy = document.getElementById('btnCopy'); +const connectorGuide = document.getElementById('connectorGuide'); + +function sizeCanvas(canvas, ctx) { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return {w: rect.width, h: rect.height}; +} + +function hexAlpha(hex, a) { + const h = hex.replace('#', ''); + return `rgba(${parseInt(h.substr(0, 2), 16)},${parseInt(h.substr(2, 2), 16)},${parseInt(h.substr(4, 2), 16)},${a})`; +} + +function getGridTickMs(duration) { + if (duration <= 5 * 60000) return 30000; + if (duration <= 60 * 60000) return 5 * 60000; + if (duration <= 4 * 3600000) return 15 * 60000; + if (duration <= 12 * 3600000) return 3600000; + if (duration <= 7 * 24 * 3600000) return 12 * 3600000; + return 24 * 3600000; +} + +function drawTimeGrid(ctx, w, h) { + const tickMs = getGridTickMs(viewportDuration); + const firstTick = Math.ceil(viewportStartT / tickMs) * tickMs; + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 1; + ctx.fillStyle = 'rgba(165,169,159,0.7)'; + ctx.font = '9.5px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let t = firstTick; t <= viewportStartT + viewportDuration; t += tickMs) { + const x = tToXf(t, w); + ctx.beginPath(); + ctx.moveTo(x + 0.5, 0); + ctx.lineTo(x + 0.5, h); + ctx.stroke(); + if (ctx === mctx && x > 15 && x < w - 15) { + const label = viewportDuration > 3 * 24 * 3600 * 1000 ? fmtDateCompact(t) : fmtClockShort(t); + ctx.fillText(label, x, h - 14); + } + } + ctx.textAlign = 'left'; +} + +function drawMainLane() { + const {w, h} = sizeCanvas(mainCanvas, mctx); + mctx.clearRect(0, 0, w, h); + drawTimeGrid(mctx, w, h); + + if (FinalTimeline.length < 2) return; + const {tStart, tEnd} = selectableRange(); + const visiblePoints = FinalTimeline.filter(p => p.t >= tStart && p.t <= tEnd); + + if (!visiblePoints.length) return; + + const runs = []; + let cur = { + tStart: visiblePoints[0].t, + tEnd: visiblePoints[0].t, + streamId: visiblePoints[0].streamId + }; + + for (let i = 1; i < visiblePoints.length; i++) { + const p = visiblePoints[i]; + + if (p.streamId === cur.streamId) { + cur.tEnd = p.t; + } else { + runs.push(cur); + cur = {tStart: p.t, tEnd: p.t, streamId: p.streamId}; + } + } + runs.push(cur); + + const blockY = Math.max(10, h * 0.22); + const blockH = h - blockY - Math.max(18, h * 0.25); + + for (const r of runs) { + const x0 = tToXf(r.tStart, w), x1 = tToXf(r.tEnd, w); + if (x1 < 0 || x0 > w) continue; + const rectW = Math.max(2, x1 - x0); + const color = colorOf(r.streamId); + const grad = mctx.createLinearGradient(0, blockY, 0, blockY + blockH); + grad.addColorStop(0, hexAlpha(color, 0.55)); + grad.addColorStop(1, hexAlpha(color, 0.3)); + mctx.fillStyle = grad; + roundRect(mctx, x0, blockY, rectW, blockH, 5); + mctx.fill(); + + mctx.lineWidth = 1.2; + mctx.strokeStyle = hexAlpha(color, 0.85); + roundRect(mctx, x0 + 0.5, blockY + 0.5, rectW - 1, blockH - 1, 5); + mctx.stroke(); + } + + const px0 = tToXf(W.patch.tStart, w); + const px1 = tToXf(W.patch.tEnd, w); + if (!(px1 < 0 || px0 > w)) { + mctx.save(); + mctx.strokeStyle = 'rgba(242, 196, 112, 0.55)'; + mctx.fillStyle = 'rgba(242, 196, 112, 0.08)'; + mctx.lineWidth = 1; + mctx.setLineDash([4, 3]); + roundRect(mctx, Math.max(0, px0), blockY - 2, Math.max(2, px1 - px0), blockH + 4, 6); + mctx.fill(); + mctx.stroke(); + mctx.setLineDash([]); + mctx.restore(); + } + + const dotY = blockY + blockH / 2; + const minSpacingPx = 3; + let lastX = -Infinity; + + for (const p of visiblePoints) { + const x = tToXf(p.t, w); + if (x < -4 || x > w + 4) continue; + if (x - lastX < minSpacingPx && !W.selected.has(p.id)) continue; + lastX = x; + + const selected = W.selected.has(p.id); + const color = colorOf(p.streamId); + + mctx.fillStyle = '#0a1320'; + mctx.beginPath(); + mctx.arc(x, dotY, selected ? 5 : 2.2, 0, Math.PI * 2); + mctx.fill(); + + mctx.fillStyle = selected ? '#f2c470' : color; + mctx.beginPath(); + mctx.arc(x, dotY, selected ? 3.6 : 1.5, 0, Math.PI * 2); + mctx.fill(); + } + + for (const p of visiblePoints) { + if (!W.selected.has(p.id)) continue; + const x = tToXf(p.t, w); + mctx.fillStyle = '#f2c470'; + mctx.shadowColor = 'rgba(242,196,112,0.8)'; + mctx.shadowBlur = 8; + mctx.beginPath(); + mctx.arc(x, blockY + blockH / 2, 4, 0, Math.PI * 2); + mctx.fill(); + mctx.shadowBlur = 0; + mctx.strokeStyle = '#fff5d6'; + mctx.lineWidth = 1.2; + mctx.stroke(); + } +} + +function roundRect(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function drawDeviceLane() { + const {w, h} = sizeCanvas(deviceCanvas, dctx); + dctx.clearRect(0, 0, w, h); + drawTimeGrid(dctx, w, h); + + const arr = (SourceData.get(W.selectedDevice) || []).filter(p => + p.t >= viewportStartT - viewportDuration * 0.2 && + p.t <= viewportStartT + viewportDuration * 1.2 + ); + const color = DeviceSources[W.selectedDevice].color; + if (arr.length < 2) return; + + const gapMs = GAP_CONFIG.device.continuousUpTo; + + let aMin = Infinity, aMax = -Infinity; + for (const p of arr) { + if (p.alt < aMin) aMin = p.alt; + if (p.alt > aMax) aMax = p.alt; + } + const pad = (aMax - aMin) * 0.18 + 1; + aMin -= pad; + aMax += pad; + + const midY = h / 2; + const ampH = h * 0.38; + const altToY = (a) => midY - (((a - aMin) / (aMax - aMin)) - 0.5) * 2 * ampH; + + const segments = []; + let seg = [arr[0]]; + for (let i = 1; i < arr.length; i++) { + if (arr[i].t - arr[i - 1].t > gapMs) { + if (seg.length > 1) segments.push(seg); + seg = []; + } + seg.push(arr[i]); + } + if (seg.length > 1) segments.push(seg); + + dctx.save(); + dctx.shadowColor = hexAlpha(color, 0.55); + dctx.shadowBlur = 6; + dctx.strokeStyle = color; + dctx.lineWidth = 1.4; + dctx.lineJoin = 'round'; + dctx.lineCap = 'round'; + for (const s of segments) { + dctx.beginPath(); + for (let i = 0; i < s.length; i++) { + const x = tToXf(s[i].t, w), y = altToY(s[i].alt); + if (i === 0) dctx.moveTo(x, y); else dctx.lineTo(x, y); + } + dctx.stroke(); + } + dctx.restore(); + + dctx.strokeStyle = color; + dctx.lineWidth = 1.3; + for (const s of segments) { + dctx.beginPath(); + for (let i = 0; i < s.length; i++) { + const x = tToXf(s[i].t, w), y = altToY(s[i].alt); + if (i === 0) dctx.moveTo(x, y); else dctx.lineTo(x, y); + } + dctx.stroke(); + } + + for (let i = 1; i < arr.length; i++) { + const dt = arr[i].t - arr[i - 1].t; + if (dt > gapMs) { + const x0 = tToXf(arr[i - 1].t, w), x1 = tToXf(arr[i].t, w); + if (x1 < 0 || x0 > w) continue; + dctx.fillStyle = 'rgba(224, 122, 107, 0.08)'; + dctx.fillRect(x0, 4, x1 - x0, h - 8); + if (x1 - x0 > 30) { + dctx.fillStyle = 'rgba(224,122,107,0.7)'; + dctx.font = '9.5px ui-monospace, monospace'; + dctx.textAlign = 'center'; + dctx.textBaseline = 'middle'; + const centerVal = (Math.max(0, x0) + Math.min(w, x1)) / 2; + dctx.fillText('gap', centerVal, h / 2); + dctx.textAlign = 'left'; + } + } + } +} + +function syncPatchBox() { + const cell = document.getElementById('deviceLaneCell'); + const w = cell.clientWidth; + if (!w) return; + const x0 = tToXf(W.patch.tStart, w); + const x1 = tToXf(W.patch.tEnd, w); + if (x1 < 0 || x0 > w) { + patchBox.style.display = 'none'; + btnCopy.style.display = 'none'; + connectorGuide.style.display = 'none'; + return; + } + patchBox.style.display = 'block'; + btnCopy.style.display = 'inline-flex'; + connectorGuide.style.display = 'block'; + patchBox.style.left = x0 + 'px'; + patchBox.style.width = Math.max(2, (x1 - x0)) + 'px'; + const center = (x0 + x1) / 2; + const btnW = btnCopy.offsetWidth || 150; + const clamped = Math.max(btnW / 2 + 4, Math.min(w - btnW / 2 - 4, center)); + btnCopy.style.left = clamped + 'px'; + connectorGuide.style.left = center + 'px'; + drawMainLane(); +} + +/* ============================================================ + WHEEL ZOOM / PAN + ============================================================ */ +function clampViewport() { + viewportDuration = Math.max(60 * 1000, viewportDuration); +} + +document.getElementById('drawer').addEventListener('wheel', (e) => { + if (e.target.closest('#historyPanel')) return; + if (e.target.closest('.picker-popup')) return; + const cell = document.getElementById('deviceLaneCell'); + const w = cell.clientWidth; + if (!w) return; + + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const rect = cell.getBoundingClientRect(); + const cursorX = Math.max(0, Math.min(w, e.clientX - rect.left)); + const tMouse = xToTf(cursorX, w); + const zoomRatio = e.deltaY > 0 ? 1.08 : 0.92; + const proposedDuration = viewportDuration * zoomRatio; + viewportDuration = Math.max(60 * 1000, proposedDuration); + viewportStartT = tMouse - (cursorX / w) * viewportDuration; + } else { + e.preventDefault(); + const shiftX = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY); + const panTimeShift = (shiftX / w) * viewportDuration * 0.8; + viewportStartT += panTimeShift; + } + + clampViewport(); + triggerDebouncedDataLoad(); + refreshAll(); +}, {passive: false}); + +/* ============================================================ + PATCH-BOX DRAG + ============================================================ */ +let patchDrag = null; +patchBox.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + const edge = e.target.dataset?.edge; + patchDrag = { + kind: edge || 'move', startX: e.clientX, + origStart: W.patch.tStart, origEnd: W.patch.tEnd + }; + document.body.style.userSelect = 'none'; +}); + +window.addEventListener('mousemove', (e) => { + if (!patchDrag) return; + const cell = document.getElementById('deviceLaneCell'); + const w = cell.clientWidth; + const dt = ((e.clientX - patchDrag.startX) / w) * viewportDuration; + const minSpan = 5000; + let ns = patchDrag.origStart, ne = patchDrag.origEnd; + + if (patchDrag.kind === 'move') { + const span = ne - ns; + ns = patchDrag.origStart + dt; + ne = ns + span; + } else if (patchDrag.kind === 'left') { + ns = Math.min(ne - minSpan, patchDrag.origStart + dt); + } else if (patchDrag.kind === 'right') { + ne = Math.max(ns + minSpan, patchDrag.origEnd + dt); + } + + W.patch.tStart = ns; + W.patch.tEnd = ne; + syncPatchBox(); + map.getSource('cand-active')?.setData(buildCandidateActiveFC()); +}); + +window.addEventListener('mouseup', () => { + if (patchDrag) { + patchDrag = null; + document.body.style.userSelect = ''; + } +}); + +/* ============================================================ + TIMELINE DRAG-TO-PAN + ============================================================ */ +let timelinePan = null; + +function beginTimelinePan(e, cellEl) { + if (e.target.closest('#patchBox')) return false; + if (e.target.closest('#btnCopy')) return false; + if (e.button !== 0) return false; + + const rect = cellEl.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + timelinePan = { + startX: e.clientX, + startY: e.clientY, + origViewportStart: viewportStartT, + cellW: rect.width, + clickX, + clickT: xToTf(clickX, rect.width), + cellId: cellEl.id, + moved: false, + cell: cellEl + }; + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + return true; +} + +function attachPanHandler(cellId) { + const el = document.getElementById(cellId); + if (!el) return; + el.addEventListener('mousedown', (e) => { + if (patchDrag) return; + beginTimelinePan(e, el); + }); + el.style.cursor = 'grab'; +} + +attachPanHandler('mainLaneCell'); +attachPanHandler('deviceLaneCell'); + +window.addEventListener('mousemove', (e) => { + if (!timelinePan) return; + const dx = e.clientX - timelinePan.startX; + if (!timelinePan.moved && Math.abs(dx) < 3) return; + timelinePan.moved = true; + + const shift = -(dx / timelinePan.cellW) * viewportDuration; + viewportStartT = timelinePan.origViewportStart + shift; + clampViewport(); + refreshAll(); + playhead.style.display = 'none'; +}); + +window.addEventListener('mouseup', () => { + if (!timelinePan) return; + const pan = timelinePan; + timelinePan = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + if (pan.moved) { + triggerDebouncedDataLoad(); + return; + } + + if (pan.cellId === 'deviceLaneCell') { + const span = W.patch.tEnd - W.patch.tStart; + let ns = pan.clickT - span / 2; + let ne = ns + span; + W.patch.tStart = ns; + W.patch.tEnd = ne; + syncPatchBox(); + map.getSource('cand-active')?.setData(buildCandidateActiveFC()); + } +}); + +/* ============================================================ + SCRUBBING + MAP FOLLOW + ============================================================ */ +let lastScrubPanAt = 0; + +function onScrub(e) { + if (timelinePan && timelinePan.moved) return; + const cell = e.currentTarget; + const rect = cell.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + const t = xToTf(x, rect.width); + W.hoverT = t; + playhead.style.display = 'block'; + playhead.style.left = x + 'px'; + + const p = interpolateAtT(FinalTimeline, t); + if (p) { + map.getSource('scrub-pt')?.setData({ + type: 'FeatureCollection', + features: [{type: 'Feature', properties: {}, geometry: {type: 'Point', coordinates: [p.lng, p.lat]}}] + }); + document.getElementById('drawerClock').textContent = fmtClock(t); + + const now = performance.now(); + if (now - lastScrubPanAt > 120) { + const b = map.getBounds(); + const pad = 0.15; + const lngSpan = b.getEast() - b.getWest(); + const latSpan = b.getNorth() - b.getSouth(); + const outside = + p.lng < b.getWest() + lngSpan * pad || + p.lng > b.getEast() - lngSpan * pad || + p.lat < b.getSouth() + latSpan * pad || + p.lat > b.getNorth() - latSpan * pad; + if (outside) { + map.easeTo({center: [p.lng, p.lat], duration: 400, essential: true}); + lastScrubPanAt = now; + } + } + } + syncPickerToHover(t); +} + +let _pickerSyncAt = 0; + +function syncPickerToHover(t) { + if (!dateSelection) return; + const now = performance.now(); + if (now - _pickerSyncAt < 80) return; + _pickerSyncAt = now; + dateSelection.setDateSilent(new Date(t)); +} + +function offScrub() { + W.hoverT = null; + playhead.style.display = 'none'; + map.getSource('scrub-pt')?.setData(emptyFC()); + + if (dateSelection) { + const centerT = viewportStartT + viewportDuration / 2; + dateSelection.setDateSilent(new Date(centerT)); + } + +} + +document.getElementById('deviceLaneCell').addEventListener('mousemove', onScrub); +document.getElementById('deviceLaneCell').addEventListener('mouseleave', offScrub); +document.getElementById('mainLaneCell').addEventListener('mousemove', onScrub); +document.getElementById('mainLaneCell').addEventListener('mouseleave', offScrub); + +function interpolateAtT(arr, t) { + if (!arr.length) return null; + if (t <= arr[0].t) return arr[0]; + if (t >= arr[arr.length - 1].t) return arr[arr.length - 1]; + let lo = 0, hi = arr.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (arr[mid].t <= t) lo = mid; else hi = mid; + } + const a = arr[lo], b = arr[hi]; + const f = (t - a.t) / (b.t - a.t); + return { + lat: a.lat + (b.lat - a.lat) * f, lng: a.lng + (b.lng - a.lng) * f, + alt: a.alt + (b.alt - a.alt) * f + }; +} + +/* ============================================================ + WEAVE OPS + ============================================================ */ +function copyToFinal() { + const streamId = W.selectedDevice; + const src = (SourceData.get(streamId) || []).filter(p => p.t >= W.patch.tStart && p.t <= W.patch.tEnd); + if (!src.length) { + toast(t('workbench.toast.patch.no_points'), true); + return; + } + + const patchSeq = (EditStore.patches.at(-1)?.seq ?? 0) + 1; + const patchRecord = { + tStart: W.patch.tStart, tEnd: W.patch.tEnd, + streamId, seq: patchSeq + }; + EditStore.patches.push(patchRecord); + + + const priorCount = FinalTimeline.filter(p => p.t >= W.patch.tStart && p.t <= W.patch.tEnd).length; + rebuildFinalInRange(W.patch.tStart, W.patch.tEnd); + const injected = FinalTimeline.filter(p => + p.t >= W.patch.tStart && p.t <= W.patch.tEnd && p.streamId === streamId + ).length; + + const niceName = nameOf(streamId); + pushAction({ + type: 'copy', + shortDesc: t('workbench.action.patch.short_description', [niceName]), + desc: t('workbench.action.patch.description', [DeviceSources[streamId == null ? 'default' : streamId].color, niceName, fmtClockShort(W.patch.tStart), fmtClockShort(W.patch.tEnd), injected, priorCount]), + payload: { + device: streamId, tStart: W.patch.tStart, tEnd: W.patch.tEnd, + pointsInjected: injected, pointsRemoved: priorCount + }, + _inverse: {patchRecord} + }); + + toast(t('workbench.toast.patch.points', [injected, niceName])); + refreshAll(); +} + +function deleteSelected() { + if (!W.selected.size) return; + + const sel = FinalTimeline.filter(p => W.selected.has(p.id) && p.sourceId != null); + if (!sel.length) { + toast(t('workbench.toast.deleted_points.none'), true); + return; + } + + const dedupedIds = [...new Set(sel.map(p => p.sourceId))]; + for (const sid of dedupedIds) EditStore.deletedPoints.add(sid); + + const tMin = Math.min(...sel.map(p => p.t)); + const tMax = Math.max(...sel.map(p => p.t)); + rebuildFinalInRange(tMin - 1, tMax + 1); + clearSelection(); + + const n = dedupedIds.length; + pushAction({ + type: 'delete', + shortDesc: t('workbench.action.delete.short_description', [n]), + desc: t('workbench.action.delete.description', [n, fmtClockShort(tMin), fmtClockShort(tMax)]), + payload: { + count: n, + tStart: tMin, tEnd: tMax, + sourceIds: dedupedIds + }, + _inverse: {sourceIds: dedupedIds, tStart: tMin - 1, tEnd: tMax + 1} + }); + + toast(t('workbench.toast.deleted_points', [n])); + refreshAll(); +} + +/* ============================================================ + MAP INTERACTIONS + ============================================================ */ +function snapshotDragGroup(pointIds) { + const group = []; + for (const id of pointIds) { + const fp = FinalTimeline.find(p => p.id === id); + if (!fp) continue; + if (fp.sourceId == null) continue; + const cached = findCachedPoint(fp.streamId, fp.sourceId); + group.push({ + pointId: fp.id, + sourceId: fp.sourceId, + streamId: fp.streamId, + originLat: fp.lat, originLng: fp.lng, + cachedOriginLat: cached?.lat ?? null, + cachedOriginLng: cached?.lng ?? null + }); + } + return group; +} + +function findCachedPoint(streamId, sourceId) { + if (streamId === '__main__') { + return MainJourneyData.find(p => p.sourceId === sourceId); + } + return SourceData.get(streamId)?.find(p => p.sourceId === sourceId); +} + +function setupMapInteractions() { + const mapEl = map.getCanvas(); + let mouseDown = false, boxStart = null; + let draggingAnchor = null, dragGroup = null, anchorOrigin = null, draggedMoved = false; + + map.on('mousemove', 'final-pts', () => { + if (W.tool === 'select' || W.tool === 'boxselect') mapEl.style.cursor = 'grab'; + }); + map.on('mouseleave', 'final-pts', () => { + mapEl.style.cursor = ''; + }); + + map.on('mousedown', (e) => { + if (W.tool === 'inspect') return; + if (e.originalEvent.button !== 0) return; + mouseDown = true; + draggedMoved = false; + + const pt = nearestFinalPoint(e.point, 14); + if (pt) { + const clickedIsSelected = W.selected.has(pt.id); + let idsToDrag; + if (clickedIsSelected && W.selected.size > 1) idsToDrag = Array.from(W.selected); + else if (W.tool === 'select') idsToDrag = [pt.id]; + else idsToDrag = null; + + if (idsToDrag) { + draggingAnchor = pt; + anchorOrigin = {lat: pt.lat, lng: pt.lng}; + dragGroup = snapshotDragGroup(idsToDrag); + map.dragPan.disable(); + return; + } + } + + if (W.tool === 'boxselect') { + boxStart = {x: e.originalEvent.clientX, y: e.originalEvent.clientY, mapPt: e.point}; + map.dragPan.disable(); + boxOverlay.style.display = 'block'; + boxOverlay.style.left = boxStart.x + 'px'; + boxOverlay.style.top = boxStart.y + 'px'; + boxOverlay.style.width = '0px'; + boxOverlay.style.height = '0px'; + } + }); + + map.on('mousemove', (e) => { + if (!mouseDown) return; + if (draggingAnchor && dragGroup) { + draggedMoved = true; + const dLat = e.lngLat.lat - anchorOrigin.lat; + const dLng = e.lngLat.lng - anchorOrigin.lng; + for (const g of dragGroup) { + const fp = FinalTimeline.find(p => p.id === g.pointId); + if (!fp) continue; + fp.lat = g.originLat + dLat; + fp.lng = g.originLng + dLng; + if (g.cachedOriginLat != null) { + const s = findCachedPoint(g.streamId, g.sourceId); + if (s) { + s.lat = g.cachedOriginLat + dLat; + s.lng = g.cachedOriginLng + dLng; + } + } + } + refreshMap(); + drawMainLane(); + drawDeviceLane(); + mapEl.style.cursor = 'grabbing'; + return; + } + if (boxStart) { + const cx = e.originalEvent.clientX, cy = e.originalEvent.clientY; + boxOverlay.style.left = Math.min(cx, boxStart.x) + 'px'; + boxOverlay.style.top = Math.min(cy, boxStart.y) + 'px'; + boxOverlay.style.width = Math.abs(cx - boxStart.x) + 'px'; + boxOverlay.style.height = Math.abs(cy - boxStart.y) + 'px'; + } + }); + + window.addEventListener('mouseup', (e) => { + if (!mouseDown) return; + mouseDown = false; + map.dragPan.enable(); + + if (draggingAnchor && dragGroup) { + if (draggedMoved) { + const anchor = FinalTimeline.find(p => p.id === draggingAnchor.id); + const dLat = anchor.lat - anchorOrigin.lat, dLng = anchor.lng - anchorOrigin.lng; + const deltaM = Math.sqrt(dLat * dLat + dLng * dLng) * 111320; + const n = dragGroup.length; + + const movedEntries = []; + for (const g of dragGroup) { + const fp = FinalTimeline.find(p => p.id === g.pointId); + if (!fp) continue; + EditStore.movedPoints.set(g.sourceId, {lat: fp.lat, lng: fp.lng}); + movedEntries.push({sourceId: g.sourceId, lat: fp.lat, lng: fp.lng}); + } + + const inverseGroup = dragGroup.map(g => ({ + pointId: g.pointId, + sourceId: g.sourceId, + streamId: g.streamId, + from: {lat: g.originLat, lng: g.originLng}, + cachedFrom: g.cachedOriginLat != null ? {lat: g.cachedOriginLat, lng: g.cachedOriginLng} : null + })); + + if (n === 1) { + const g = dragGroup[0]; + const srcName = nameOf(g.streamId) ?? 'default'; + pushAction({ + type: 'move', + shortDesc: t('workbench.action.move.single.short_description', [fmtClockShort(anchor.t)]), + desc: t('workbench.action.move.single.description', [fmtClockShort(anchor.t), deltaM.toFixed(1), srcName]), + payload: {count: 1, points: movedEntries, deltaMeters: +deltaM.toFixed(2)}, + _inverse: {group: inverseGroup} + }); + } else { + pushAction({ + type: 'move', + shortDesc: t('workbench.action.move.multi.short_description', [n]), + desc: t('workbench.action.move.multi.description', [n, deltaM.toFixed(1)]), + payload: {count: n, points: movedEntries, deltaMeters: +deltaM.toFixed(2)}, + _inverse: {group: inverseGroup} + }); + } + drawMainLane(); + drawDeviceLane(); + } else { + if (!e.shiftKey && !e.metaKey && !e.ctrlKey) clearSelection(); + if (W.selected.has(draggingAnchor.id)) W.selected.delete(draggingAnchor.id); + else { + W.selected.add(draggingAnchor.id); + W.selectionAnchorId = draggingAnchor.id; + } + scrollTimelineToTime(draggingAnchor.t); + refreshAll(); + } + draggingAnchor = null; + dragGroup = null; + anchorOrigin = null; + mapEl.style.cursor = ''; + return; + } + + if (boxStart) { + const x1 = boxStart.mapPt.x, y1 = boxStart.mapPt.y; + const endRect = map.getContainer().getBoundingClientRect(); + const x2 = e.clientX - endRect.left, y2 = e.clientY - endRect.top; + boxOverlay.style.display = 'none'; + boxStart = null; + if (Math.abs(x2 - x1) < 4 && Math.abs(y2 - y1) < 4) return; + const c1 = map.unproject([Math.min(x1, x2), Math.min(y1, y2)]); + const c2 = map.unproject([Math.max(x1, x2), Math.max(y1, y2)]); + const minLng = Math.min(c1.lng, c2.lng), maxLng = Math.max(c1.lng, c2.lng); + const minLat = Math.min(c1.lat, c2.lat), maxLat = Math.max(c1.lat, c2.lat); + if (!e.shiftKey) clearSelection(); + + let added = 0; + let latestT = -Infinity; + let latestId = null; + + for (const p of FinalTimeline) { + if (p.sourceId == null) continue; + if (p.lat >= minLat && p.lat <= maxLat && p.lng >= minLng && p.lng <= maxLng) { + W.selected.add(p.id); + added++; + if (p.t > latestT) { latestT = p.t; latestId = p.id; } + } + } + if (latestId) { + W.selectionAnchorId = latestId; + scrollTimelineToTime(latestT); + } + if (added > 0) { + toast(t('workbench.toast.selected_points', [added])); + activateTool('select'); + } + refreshAll(); + } + }); +} + +function clearSelection() { + W.selected.clear(); + W.selectionAnchorId = null; +} + +function scrollTimelineToTime(t) { + if (!Number.isFinite(t)) return; + const margin = viewportDuration * 0.1; + const inView = t >= viewportStartT + margin && + t <= viewportStartT + viewportDuration - margin; + if (inView) return; + viewportStartT = t - viewportDuration / 2; + clampViewport(); + triggerDebouncedDataLoad(); +} + +function nearestFinalPoint(screenPt, thresholdPx) { + let best = null, bestD = thresholdPx * thresholdPx; + const {tStart, tEnd} = selectableRange(); + for (const p of FinalTimeline) { + if (p.t < tStart || p.t > tEnd) continue; + if (p.sourceId == null) continue; + const proj = map.project([p.lng, p.lat]); + const dx = proj.x - screenPt.x, dy = proj.y - screenPt.y; + const d = dx * dx + dy * dy; + if (d < bestD) { + bestD = d; + best = p; + } + } + return best; +} + +/* ============================================================ + UI + ============================================================ */ +function activateTool(tool) { + W.tool = tool; + document.querySelectorAll('.head-btn[data-tool]').forEach(x => + x.classList.toggle('active', x.dataset.tool === tool)); + if (tool === 'inspect') { + document.getElementById('map').classList.add('inspect'); + clearSelection(); + renderSelectionInfo(); + } else { + document.getElementById('map').classList.remove('inspect'); + } + refreshMap(); + drawMainLane(); + drawDeviceLane(); +} + +document.querySelectorAll('.head-btn[data-tool]').forEach(b => { + b.addEventListener('click', () => activateTool(b.dataset.tool)); +}); + +document.getElementById('deviceSelect').addEventListener('change', (e) => { + W.selectedDevice = e.target.value; + const name = nameOf(W.selectedDevice === 'default' ? null : W.selectedDevice); + document.getElementById('deviceSubLabel').textContent = `(${name})`; + refreshAll(); +}); + +btnCopy.addEventListener('click', copyToFinal); +document.getElementById('btnDelete').addEventListener('click', deleteSelected); + +function streamIdToWire(streamId) { + if (streamId === '__main__') return undefined; + if (streamId === 'default') return null; + return streamId; +} + + +let commitPayloadCache = null; + +function openCommit() { + const modal = document.getElementById('commitModal'); + const summaryEl = document.getElementById('commitSummaryDetail'); + const topSummary = document.getElementById('commitSummary'); + + // Build payload (same as before) + const active = History.filter(a => !a.undone); + + commitPayloadCache = { + editStore: { + patches: EditStore.patches.map(p => ({ + seq: p.seq, + tStart: p.tStart, + tEnd: p.tEnd, + deviceId: streamIdToWire(p.streamId) + })), + deletedPoints: Array.from(EditStore.deletedPoints).map(sourceId => ({sourceId})), + movedPoints: Array.from(EditStore.movedPoints.entries()).map( + ([sourceId, v]) => ({sourceId, lat: v.lat, lng: v.lng}) + ) + }, + actions: active.map(a => ({seq: a.seq, type: a.type, at: a.at, ...a.payload})), + finalState: { + tStart: FinalTimeline[0]?.t ?? 0, + tEnd: FinalTimeline[FinalTimeline.length - 1]?.t ?? 0, + } + }; + + // Build readable summary + let html = ''; + + if (EditStore.patches.length) { + html += `
${t('workbench.commit.part.patches.title')}
`; + for (const p of EditStore.patches) { + const name = nameOf(p.streamId); + const color = DeviceSources?.color ?? '#888'; + const pointsCount = FinalTimeline.filter(pt => + pt.t >= p.tStart && pt.t <= p.tEnd && pt.streamId === p.streamId + ).length; + html += `
+ + ${escapeHtml(name)} · ${fmtDateCompact(p.tStart)} – ${fmtClockShort(p.tEnd)} + ${t('workbench.commit.part.patches.count', [pointsCount])} +
`; + } + html += `
`; + } + + if (EditStore.deletedPoints.size) { + html += `
${t('workbench.commit.part.deleted.title')}
+
+ ${EditStore.deletedPoints.size} points removed +
+
`; + } + + if (EditStore.movedPoints.size) { + html += `
${t('workbench.commit.part.moved.title')}
+
${EditStore.movedPoints.size} points shifted
+
`; + } + + summaryEl.innerHTML = html; + topSummary.textContent = t('workbench.commit.part.actions.count', [History.length]); + + modal.classList.add('open'); +} + +function closeCommit() { + document.getElementById('commitModal').classList.remove('open'); +} + +document.getElementById('btnCommit').addEventListener('click', openCommit); +document.getElementById('commitClose').addEventListener('click', closeCommit); +document.getElementById('commitCancel').addEventListener('click', closeCommit); +document.getElementById('commitConfirm').addEventListener('click', async () => { + if (!commitPayloadCache) return; + try { + const res = await fetch(window.contextPath + '/api/v2/workbench/commit', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(commitPayloadCache) + }); + const data = await res.json(); + toast(data.message, !data.success); + if (data.success) { + closeCommit(); + triggerDebouncedDataLoad(); + } + } catch (err) { + console.error(err); + toast(t('workbench.commit.failure.network'), true); + } +}); + +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return; + if (e.target.isContentEditable) return; + + if (e.key === '?') { + e.preventDefault(); + openHelp(); + return; + } + + // Esc closes help first if open, then falls through to existing handlers + if (e.key === 'Escape' && helpPanel.classList.contains('open')) { + closeHelp(); + return; + } + + + // Always-available + if (e.key === 'Escape') { + closeCommit(); + clearSelection(); + refreshAll(); + return; + } + if (e.key === '1') { activateTool('inspect'); return; } + if (e.key === '2') { activateTool('select'); return; } + if (e.key === '3') { activateTool('boxselect'); return; } + + // Delete / Backspace on a non-empty selection + if ((e.key === 'Delete' || e.key === 'Backspace') && W.selected.size) { + e.preventDefault(); + deleteSelected(); + return; + } + + // Selection navigation with Ctrl + const ctrl = e.ctrlKey || e.metaKey; + if (ctrl && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + e.preventDefault(); + const dir = e.key === 'ArrowLeft' ? -1 : 1; + if (e.shiftKey) extendSelection(dir); + else moveSelection(dir); + return; + } + if (ctrl && (e.key === 'Home' || e.key === 'End')) { + e.preventDefault(); + const toEnd = e.key === 'End'; + if (e.shiftKey) extendSelectionToEdge(toEnd); + else moveSelectionToEdge(toEnd); + return; + } + if (ctrl && e.key.toLowerCase() === 'a') { + e.preventDefault(); + selectAllInWindow(); + return; + } + +}); + +window.addEventListener('resize', () => { + refreshAll(); +}); + +function updateButtons() { + document.getElementById('btnDelete').disabled = W.selected.size === 0; +} + +function updateWovenCount() { + let n = 0, last = null; + for (const p of FinalTimeline) { + if (p.streamId !== '__main__' && p.streamId !== last) n++; + last = p.streamId; + } + document.getElementById('wovenCount').textContent = n; +} + + +function selectableTimelinePoints() { + const { tStart, tEnd } = selectableRange(); + return FinalTimeline.filter(p => p.t >= tStart && p.t <= tEnd && p.sourceId != null); +} + +function getAnchorPoint() { + if (W.selectionAnchorId) { + const p = FinalTimeline.find(x => x.id === W.selectionAnchorId); + if (p) return p; + } + // Fallback: closest-to-viewport-center if there's no anchor yet + const center = viewportStartT + viewportDuration / 2; + let best = null, bestDt = Infinity; + for (const p of selectableTimelinePoints()) { + const dt = Math.abs(p.t - center); + if (dt < bestDt) { bestDt = dt; best = p; } + } + return best; +} + +function moveSelection(dir) { + const points = selectableTimelinePoints(); + if (!points.length) return; + + const anchor = getAnchorPoint(); + let target; + + if (!anchor) { + target = dir > 0 ? points[0] : points[points.length - 1]; + } else { + // If multiple selected, collapse to the appropriate edge first + if (W.selected.size > 1) { + const selectedPoints = points.filter(p => W.selected.has(p.id)); + target = dir > 0 + ? selectedPoints[selectedPoints.length - 1] + : selectedPoints[0]; + } else { + const idx = points.findIndex(p => p.id === anchor.id); + if (idx === -1) { + target = dir > 0 ? points[0] : points[points.length - 1]; + } else { + const newIdx = Math.max(0, Math.min(points.length - 1, idx + dir)); + target = points[newIdx]; + } + } + } + + if (!target) return; + W.selected.clear(); + W.selected.add(target.id); + W.selectionAnchorId = target.id; + ensurePointVisible(target); + refreshAll(); +} + +function ensurePointVisible(p) { + // Timeline: pan viewport if the point is near or beyond the edge + const margin = viewportDuration * 0.1; + if (p.t < viewportStartT + margin || p.t > viewportStartT + viewportDuration - margin) { + viewportStartT = p.t - viewportDuration / 2; + clampViewport(); + triggerDebouncedDataLoad(); + } + + // Map: only pan if the point is currently outside the visible bounds + const b = map.getBounds(); + if (p.lat < b.getSouth() || p.lat > b.getNorth() || + p.lng < b.getWest() || p.lng > b.getEast()) { + map.easeTo({ center: [p.lng, p.lat], duration: 350 }); + } +} + +function extendSelection(dir) { + const points = selectableTimelinePoints(); + if (!points.length) return; + + const anchor = getAnchorPoint(); + if (!anchor) { moveSelection(dir); return; } + + const idx = points.findIndex(p => p.id === anchor.id); + if (idx === -1) { moveSelection(dir); return; } + + const newIdx = Math.max(0, Math.min(points.length - 1, idx + dir)); + if (newIdx === idx) return; + const target = points[newIdx]; + + // Toggle: if the target is already selected, this means we're shrinking + // back toward the anchor — deselect the *previous* anchor. + if (W.selected.has(target.id)) { + W.selected.delete(anchor.id); + } else { + W.selected.add(target.id); + } + W.selectionAnchorId = target.id; + ensurePointVisible(target); + refreshAll(); +} + +function moveSelectionToEdge(toEnd) { + const points = selectableTimelinePoints(); + if (!points.length) return; + const target = toEnd ? points[points.length - 1] : points[0]; + W.selected.clear(); + W.selected.add(target.id); + W.selectionAnchorId = target.id; + ensurePointVisible(target); + refreshAll(); +} + +function extendSelectionToEdge(toEnd) { + const points = selectableTimelinePoints(); + if (!points.length) return; + + const anchor = getAnchorPoint(); + if (!anchor) { moveSelectionToEdge(toEnd); return; } + + const anchorIdx = points.findIndex(p => p.id === anchor.id); + if (anchorIdx === -1) { moveSelectionToEdge(toEnd); return; } + + const startIdx = toEnd ? anchorIdx : 0; + const endIdx = toEnd ? points.length - 1 : anchorIdx; + for (let i = startIdx; i <= endIdx; i++) W.selected.add(points[i].id); + const target = toEnd ? points[points.length - 1] : points[0]; + W.selectionAnchorId = target.id; + ensurePointVisible(target); + refreshAll(); +} + +function selectAllInWindow() { + const points = selectableTimelinePoints(); + if (!points.length) return; + for (const p of points) W.selected.add(p.id); + W.selectionAnchorId = points[points.length - 1].id; + refreshAll(); +} + +function toast(msg, warn) { + const el = document.createElement('div'); + el.className = 'toast'; + if (warn) el.style.borderColor = 'rgba(224,122,107,0.4)'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => { + el.style.transition = 'opacity .35s'; + el.style.opacity = '0'; + }, 2400); + setTimeout(() => el.remove(), 2800); +} +/* ============================================================ + SELECTION INFO PANEL + ============================================================ */ +const selectionInfoEl = document.getElementById('selectionInfo'); +selectionInfoEl.querySelector('.sel-info-close').addEventListener('click', () => { + clearSelection(); + refreshAll(); +}); + +function renderSelectionInfo() { + if (!selectionInfoEl) return; + + if (W.selected.size === 0) { + selectionInfoEl.style.display = 'none'; + return; + } + + selectionInfoEl.style.display = 'block'; + + const titleEl = selectionInfoEl.querySelector('.sel-info-title'); + const bodyEl = selectionInfoEl.querySelector('.sel-info-body'); + + if (W.selected.size === 1) { + const id = [...W.selected][0]; + const p = FinalTimeline.find(x => x.id === id); + if (!p) { + // Stale selection — shouldn't normally happen, but be safe + selectionInfoEl.style.display = 'none'; + return; + } + titleEl.textContent = t('workbench.selection_info.single.headline'); + bodyEl.innerHTML = renderSinglePointBody(p); + bindSinglePointActions(p); + } else { + titleEl.textContent = t('workbench.selection_info.multi.headline', [W.selected.size]); + bodyEl.innerHTML = renderMultiSelectionBody(); + } +} + +function renderSinglePointBody(p) { + const isSynthetic = p.sourceId == null; + const isMain = p.streamId === '__main__'; + const isMoved = p.sourceId != null && EditStore.movedPoints.has(p.sourceId); + const patchInfo = patchOwnerAt(p.t); + const isPatched = !isMain && patchInfo.hasPatch && patchInfo.owner === p.streamId; + + const streamLabel = isMain ? t('workbench.timeline.main.title') + : (nameOf(p.streamId) ?? p.streamId); + const streamColor = colorOf(p.streamId) ?? '#888'; + + const tags = []; + if (isSynthetic) { + tags.push(`${t('workbench.selection_info.tags.generated')}`); + } + if (isMoved) tags.push(`${t('workbench.selection_info.tags.moved')}`); + if (isPatched) tags.push(`${t('workbench.selection_info.tags.patched')}`); + + const rows = [ + row(t('workbench.selection_info.keys.stream'), `${escapeHtml(streamLabel)}`), + row(t('workbench.selection_info.keys.time'), `${fmtDateFull(p.t)}`), + row(t('workbench.selection_info.keys.clock'), `${fmtClock(p.t)}`), + row(t('workbench.selection_info.keys.lat_lng'), `${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`), + row(t('workbench.selection_info.keys.alt'), `${p.alt.toFixed(1)} m`), + row(t('workbench.selection_info.keys.source_id'), `${p.sourceId ?? '—'}`), + row(t('workbench.selection_info.keys.ui_id'), `${escapeHtml(p.id)}`) + ]; + + return rows.join('') + + (tags.length ? `
${tags.join('')}
` : '') + + `
+ + ${isSynthetic ? '' : ``} +
`; +} + +function renderMultiSelectionBody() { + const points = FinalTimeline.filter(p => W.selected.has(p.id)); + if (!points.length) return '
No matching points in current view
'; + + const byStream = new Map(); + let synthetic = 0, moved = 0; + let tMin = Infinity, tMax = -Infinity; + + for (const p of points) { + byStream.set(p.streamId, (byStream.get(p.streamId) || 0) + 1); + if (p.sourceId == null) synthetic++; + else if (EditStore.movedPoints.has(p.sourceId)) moved++; + if (p.t < tMin) tMin = p.t; + if (p.t > tMax) tMax = p.t; + } + + const breakdown = [...byStream.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([sid, n]) => { + const label = sid === '__main__' ? t('workbench.timeline.main.title') : (nameOf(sid) ?? sid); + const color = colorOf(sid) ?? '#888'; + return `${escapeHtml(label)} · ${n}`; + }); + + const sameDay = new Date(tMin).toDateString() === new Date(tMax).toDateString(); + const timeRange = sameDay + ? `${fmtClockShort(tMin)} – ${fmtClockShort(tMax)} · ${fmtDateFull(tMin).split(',')[0]}` + : `${fmtDateFull(tMin)} – ${fmtDateFull(tMax)}`; + + const rows = [ + row(t('workbench.selection_info.keys.range'), `${escapeHtml(timeRange)}`), + row(t('workbench.selection_info.keys.span'), `${fmtDuration(tMax - tMin)}`) + ]; + if (synthetic) rows.push(row(t('workbench.selection_info.keys.generated'), `${synthetic}`)); + if (moved) rows.push(row(t('workbench.selection_info.keys.moved'), `${moved}`)); + + return rows.join('') + + `
${breakdown.join('')}
`; +} + +function row(k, vHtml) { + return `
${escapeHtml(k)}${vHtml}
`; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function fmtDuration(ms) { + if (ms < 1000) return `${ms} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + return `${h}h ${m}m`; +} + +function bindSinglePointActions(p) { + selectionInfoEl.querySelectorAll('.sel-info-btn').forEach(btn => { + btn.addEventListener('click', () => { + const act = btn.dataset.act; + if (act === 'center') { + map.easeTo({ center: [p.lng, p.lat], duration: 400 }); + } else if (act === 'delete') { + deleteSelected(); + } + }); + }); +} + +/* ============================================================ + HELP PANEL + ============================================================ */ +const helpPanel = document.getElementById('helpPanel'); + +function openHelp() { + helpPanel.classList.add('open'); + helpPanel.setAttribute('aria-hidden', 'false'); +} + +function closeHelp() { + helpPanel.classList.remove('open'); + helpPanel.setAttribute('aria-hidden', 'true'); +} + +document.getElementById('btnHelp').addEventListener('click', openHelp); +helpPanel.querySelector('.help-panel-close').addEventListener('click', closeHelp); +helpPanel.querySelector('.help-panel-backdrop').addEventListener('click', closeHelp); + +requestAnimationFrame(() => { + refreshAll(); +}); \ No newline at end of file diff --git a/src/main/resources/static/map/colored.json b/src/main/resources/static/map/colored.json deleted file mode 100644 index d5e4af978..000000000 --- a/src/main/resources/static/map/colored.json +++ /dev/null @@ -1,2664 +0,0 @@ -{ - "version": 8, - "name": "Bright", - "metadata": { - - "mapbox:type": "template", - "maputnik:renderer": "mlgljs" - }, - "center": [0, 0], - "zoom": 1, - "bearing": 0, - "pitch": 0, - "sources": { - "dedicatedcode": { - "type": "vector", - "url": "https://tiles.dedicatedcode.com/planet", - "attribution": "© OpenFreeMap © OSM", - "maxzoom":14, - "minzoom":0 - }, - "terrain-source": { - "type": "raster-dem", - "tiles": [ - "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp" - ], - "tileSize": 256, - "encoding": "terrarium", - "maxzoom": 14, - "attribution": "© Mapterhorn" - }, - "satellite-source": { - "type": "raster", - "tiles": [ - "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" - ], - "tileSize": 256, - "maxzoom": 18, - "attribution": "Powered by Esri | Sources: Esri, Maxar, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" - } - }, - "terrain": { - "source": "terrain-source", - "exaggeration": 1 - }, - "fog": { - "range": [0.5, 10], - "color": "#144272", - "high-color": "#010b19", - "space-color": "#010b19", - "horizon-blend": 0.1 - }, - "light": { - "anchor": "viewport", - "color": "#f0f9ff", - "intensity": 0.3, - "position": [1.15, 210, 30] - }, - "sky": { - "sky-color": "#010409", - "horizon-color": "#144272", - "fog-color": "#010409", - "fog-ground-blend": 0.5, - "horizon-fog-blend": 0.95, - "sky-horizon-blend": 0.7, - "atmosphere-blend": [ - "interpolate", ["linear"], ["zoom"], - 2, 1.0, - 6, 0.0 - ] - }, - "glyphs": "/fonts/{fontstack}/{range}.pbf", - "sprite": "https://openmaptiles.github.io/osm-bright-gl-style/sprite", - "layers": [ - { - "id": "background", - "type": "background", - "paint": {"background-color": "#f8f4f0"} - }, - { - "id": "landcover-glacier", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landcover", - "filter": ["==", "subclass", "glacier"], - "layout": {"visibility": "visible"}, - "paint": { - "fill-color": "#fff", - "fill-opacity": {"base": 1, "stops": [[0, 0.9], [10, 0.3]]} - } - }, - { - "id": "landuse-residential", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": [ - "all", - ["in", "class", "residential", "suburb", "neighbourhood"] - ], - "layout": {"visibility": "visible"}, - "paint": { - "fill-color": { - "base": 1, - "stops": [ - [12, "hsla(30, 19%, 90%, 0.4)"], - [16, "hsla(30, 19%, 90%, 0.2)"] - ] - } - } - }, - { - "id": "landuse-commercial", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": [ - "all", - ["==", "$type", "Polygon"], - ["==", "class", "commercial"] - ], - "layout": {"visibility": "visible"}, - "paint": {"fill-color": "hsla(0, 60%, 87%, 0.23)"} - }, - { - "id": "landuse-industrial", - "type": "fill", - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": [ - "all", - ["==", "$type", "Polygon"], - ["in", "class", "industrial", "garages", "dam"] - ], - "layout": {"visibility": "visible"}, - "paint": {"fill-color": "hsla(49, 100%, 88%, 0.34)"} - }, - { - "id": "landuse-cemetery", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": ["==", "class", "cemetery"], - "paint": {"fill-color": "#e0e4dd"} - }, - { - "id": "landuse-hospital", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": ["==", "class", "hospital"], - "paint": {"fill-color": "#fde"} - }, - { - "id": "landuse-school", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": ["==", "class", "school"], - "paint": {"fill-color": "#f0e8f8"} - }, - { - "id": "landuse-railway", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landuse", - "filter": ["==", "class", "railway"], - "layout": {"visibility": "visible"}, - "paint": {"fill-color": "hsla(30, 19%, 90%, 0.4)"} - }, - { - "id": "landcover-wood", - "type": "fill", - "metadata": { - "mapbox:group": "1444849388993.3071" - }, - "source": "dedicatedcode", - "source-layer": "landcover", - "filter": ["==", "class", "wood"], - "paint": { - "fill-antialias": { - "base": 1, - "stops": [[0, false], [9, true]] - }, - "fill-color": "rgba(25, 176, 16, 0.23)", - "fill-opacity": 1, - "fill-outline-color": "rgba(0, 0, 0, 0)" - }, - "layout": {"visibility": "visible"} - }, - { - "id": "landcover-grass", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "landcover", - "filter": ["==", "class", "grass"], - "paint": {"fill-color": "#d8e8c8", "fill-opacity": 1} - }, - { - "id": "landcover-grass-park", - "type": "fill", - "metadata": {"mapbox:group": "1444849388993.3071"}, - "source": "dedicatedcode", - "source-layer": "park", - "filter": ["==", "class", "public_park"], - "paint": {"fill-color": "#d8e8c8", "fill-opacity": 0.8} - }, - { - "id": "satellite-layer", - "type": "raster", - "source": "satellite-source", - "paint": { - "raster-opacity": 0, - "raster-opacity-transition": { - "duration": 500 - } - }, - "layout": {"visibility": "visible"} - }, - { - "id": "waterway_tunnel", - "type": "line", - "source": "dedicatedcode", - "source-layer": "waterway", - "minzoom": 14, - "filter": [ - "all", - ["in", "class", "river", "stream", "canal"], - ["==", "brunnel", "tunnel"] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-dasharray": [2, 4], - "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} - } - }, - { - "id": "waterway-other", - "type": "line", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "waterway", - "filter": [ - "all", - ["!in", "class", "canal", "river", "stream"], - ["==", "intermittent", 0] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 2]]} - } - }, - { - "id": "waterway-other-intermittent", - "type": "line", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "waterway", - "filter": [ - "all", - ["!in", "class", "canal", "river", "stream"], - ["==", "intermittent", 1] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-dasharray": [4, 3], - "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 2]]} - } - }, - { - "id": "waterway-stream-canal", - "type": "line", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "waterway", - "filter": [ - "all", - ["in", "class", "canal", "stream"], - ["!=", "brunnel", "tunnel"], - ["==", "intermittent", 0] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} - } - }, - { - "id": "waterway-stream-canal-intermittent", - "type": "line", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "waterway", - "filter": [ - "all", - ["in", "class", "canal", "stream"], - ["!=", "brunnel", "tunnel"], - ["==", "intermittent", 1] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-dasharray": [4, 3], - "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} - } - }, - { - "id": "waterway-river", - "type": "line", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "waterway", - "filter": [ - "all", - ["==", "class", "river"], - ["!=", "brunnel", "tunnel"], - ["==", "intermittent", 0] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-width": {"base": 1.2, "stops": [[10, 0.8], [20, 6]]} - } - }, - { - "id": "waterway-river-intermittent", - "type": "line", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "waterway", - "filter": [ - "all", - ["==", "class", "river"], - ["!=", "brunnel", "tunnel"], - ["==", "intermittent", 1] - ], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "#a0c8f0", - "line-dasharray": [3, 2.5], - "line-width": {"base": 1.2, "stops": [[10, 0.8], [20, 6]]} - } - }, - { - "id": "water-offset", - "type": "fill", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "water", - "maxzoom": 8, - "filter": ["==", "$type", "Polygon"], - "layout": {"visibility": "visible"}, - "paint": { - "fill-color": "#a0c8f0", - "fill-opacity": 1, - "fill-translate": {"base": 1, "stops": [[6, [2, 0]], [8, [0, 0]]]} - } - }, - { - "id": "water", - "type": "fill", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "water", - "filter": ["all", ["!=", "intermittent", 1], ["!=", "brunnel", "tunnel"]], - "layout": {"visibility": "visible"}, - "paint": {"fill-color": "hsl(210, 67%, 85%)"} - }, - { - "id": "water-intermittent", - "type": "fill", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "water", - "filter": ["all", ["==", "intermittent", 1]], - "layout": {"visibility": "visible"}, - "paint": {"fill-color": "hsl(210, 67%, 85%)", "fill-opacity": 0.7} - }, - { - "id": "water-pattern", - "type": "fill", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "water", - "filter": ["all"], - "layout": {"visibility": "visible"}, - "paint": {"fill-pattern": "wave", "fill-translate": [0, 2.5]} - }, - { - "id": "hillshading", - "type": "hillshade", - "paint": { - "hillshade-exaggeration": 1, - "hillshade-shadow-color": [ - "rgba(138, 138, 138, 1)" - ], - "hillshade-method": "igor", - "hillshade-illumination-anchor": "viewport", - "hillshade-highlight-color": [ - "rgba(255, 255, 255, 1)" - ] - }, - "layout": { - "visibility": "visible" - }, - "source": "terrain-source", - "maxzoom": 24 - }, - { - "id": "landcover-ice-shelf", - "type": "fill", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "landcover", - "filter": ["==", "subclass", "ice_shelf"], - "layout": {"visibility": "visible"}, - "paint": { - "fill-color": "#fff", - "fill-opacity": {"base": 1, "stops": [[0, 0.9], [10, 0.3]]} - } - }, - { - "id": "landcover-sand", - "type": "fill", - "metadata": {"mapbox:group": "1444849382550.77"}, - "source": "dedicatedcode", - "source-layer": "landcover", - "filter": ["==", "class", "sand"], - "layout": {"visibility": "visible"}, - "paint": {"fill-color": "rgba(245, 238, 188, 1)", "fill-opacity": 1} - }, - { - "id": "building", - "type": "fill", - "metadata": {"mapbox:group": "1444849364238.8171"}, - "source": "dedicatedcode", - "source-layer": "building", - "paint": { - "fill-antialias": true, - "fill-color": {"base": 1, "stops": [[15.5, "#f2eae2"], [16, "#dfdbd7"]]} - } - }, - { - "id": "building-top", - "type": "fill", - "metadata": {"mapbox:group": "1444849364238.8171"}, - "source": "dedicatedcode", - "source-layer": "building", - "layout": {"visibility": "visible"}, - "paint": { - "fill-color": "#f2eae2", - "fill-opacity": {"base": 1, "stops": [[13, 0], [16, 1]]}, - "fill-outline-color": "#dfdbd7", - "fill-translate": {"base": 1, "stops": [[14, [0, 0]], [16, [-2, -2]]]} - } - }, - { - "id": "tunnel-service-track-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "service", "track"] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#cfcdca", - "line-dasharray": [0.5, 0.25], - "line-width": {"base": 1.2, "stops": [[15, 1], [16, 4], [20, 11]]} - } - }, - { - "id": "tunnel-motorway-link-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["==", "class", "motorway"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round", "visibility": "visible"}, - "paint": { - "line-color": "rgba(200, 147, 102, 1)", - "line-dasharray": [0.5, 0.25], - "line-width": { - "base": 1.2, - "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] - } - } - }, - { - "id": "tunnel-minor-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#cfcdca", - "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[12, 0.5], [13, 1], [14, 4], [20, 15]] - }, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "tunnel-link-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "trunk", "primary", "secondary", "tertiary"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] - }, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "tunnel-secondary-tertiary-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "secondary", "tertiary"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": {"base": 1.2, "stops": [[8, 1.5], [20, 17]]}, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "tunnel-trunk-primary-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "primary", "trunk"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-width": { - "base": 1.2, - "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] - } - } - }, - { - "id": "tunnel-motorway-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["==", "class", "motorway"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round", "visibility": "visible"}, - "paint": { - "line-color": "#e9ac77", - "line-dasharray": [0.5, 0.25], - "line-width": { - "base": 1.2, - "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] - } - } - }, - { - "id": "tunnel-path-steps-casing", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "tunnel"], - ["==", "class", "path"], - ["==", "subclass", "steps"] - ], - "layout": {"line-cap": "butt", "line-join": "round"}, - "paint": { - "line-color": "#cfcdca", - "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[12, 0.5], [13, 1], [14, 2], [20, 9.25]] - }, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "tunnel-path-steps", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "tunnel"], - ["==", "class", "path"], - ["==", "subclass", "steps"] - ], - "layout": {"line-join": "bevel", "line-cap": "butt"}, - "paint": { - "line-color": "#fff", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] - }, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "tunnel-path", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "tunnel"], - ["==", "class", "path"], - ["!=", "subclass", "steps"] - ], - "paint": { - "line-color": "#cba", - "line-dasharray": [1.5, 0.75], - "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} - } - }, - { - "id": "tunnel-motorway-link", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["==", "class", "motorway"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round", "visibility": "visible"}, - "paint": { - "line-color": "rgba(244, 209, 158, 1)", - "line-width": { - "base": 1.2, - "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] - } - } - }, - { - "id": "tunnel-service-track", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "service", "track"] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fff", - "line-width": {"base": 1.2, "stops": [[15.5, 0], [16, 2], [20, 7.5]]} - } - }, - { - "id": "tunnel-link", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "trunk", "primary", "secondary", "tertiary"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fff4c6", - "line-width": { - "base": 1.2, - "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] - } - } - }, - { - "id": "tunnel-minor", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fff", - "line-opacity": 1, - "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} - } - }, - { - "id": "tunnel-secondary-tertiary", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "secondary", "tertiary"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fff4c6", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 10]]} - } - }, - { - "id": "tunnel-trunk-primary", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["in", "class", "primary", "trunk"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fff4c6", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} - } - }, - { - "id": "tunnel-motorway", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "tunnel"], - ["==", "class", "motorway"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round", "visibility": "visible"}, - "paint": { - "line-color": "#ffdaa6", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} - } - }, - { - "id": "tunnel-railway", - "type": "line", - "metadata": {"mapbox:group": "1444849354174.1904"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]], - "paint": { - "line-color": "#bbb", - "line-dasharray": [2, 2], - "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} - } - }, - { - "id": "ferry", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["in", "class", "ferry"]], - "layout": {"line-join": "round", "visibility": "visible"}, - "paint": { - "line-color": "rgba(108, 159, 182, 1)", - "line-dasharray": [2, 2], - "line-width": 1.1 - } - }, - { - "id": "aeroway-taxiway-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "aeroway", - "minzoom": 12, - "filter": ["all", ["in", "class", "taxiway"]], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "rgba(153, 153, 153, 1)", - "line-opacity": 1, - "line-width": {"base": 1.5, "stops": [[11, 2], [17, 12]]} - } - }, - { - "id": "aeroway-runway-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "aeroway", - "minzoom": 12, - "filter": ["all", ["in", "class", "runway"]], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "rgba(153, 153, 153, 1)", - "line-opacity": 1, - "line-width": {"base": 1.5, "stops": [[11, 5], [17, 55]]} - } - }, - { - "id": "aeroway-area", - "type": "fill", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "aeroway", - "minzoom": 4, - "filter": [ - "all", - ["==", "$type", "Polygon"], - ["in", "class", "runway", "taxiway"] - ], - "layout": {"visibility": "visible"}, - "paint": { - "fill-color": "rgba(255, 255, 255, 1)", - "fill-opacity": {"base": 1, "stops": [[13, 0], [14, 1]]} - } - }, - { - "id": "aeroway-taxiway", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "aeroway", - "minzoom": 4, - "filter": [ - "all", - ["in", "class", "taxiway"], - ["==", "$type", "LineString"] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "rgba(255, 255, 255, 1)", - "line-opacity": {"base": 1, "stops": [[11, 0], [12, 1]]}, - "line-width": {"base": 1.5, "stops": [[11, 1], [17, 10]]} - } - }, - { - "id": "aeroway-runway", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "aeroway", - "minzoom": 4, - "filter": [ - "all", - ["in", "class", "runway"], - ["==", "$type", "LineString"] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "rgba(255, 255, 255, 1)", - "line-opacity": {"base": 1, "stops": [[11, 0], [12, 1]]}, - "line-width": {"base": 1.5, "stops": [[11, 4], [17, 50]]} - } - }, - { - "id": "road_area_pier", - "type": "fill", - "metadata": {}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "$type", "Polygon"], ["==", "class", "pier"]], - "layout": {"visibility": "visible"}, - "paint": {"fill-antialias": true, "fill-color": "#f8f4f0"} - }, - { - "id": "road_pier", - "type": "line", - "metadata": {}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "pier"]], - "layout": {"line-cap": "round", "line-join": "round"}, - "paint": { - "line-color": "#f8f4f0", - "line-width": {"base": 1.2, "stops": [[15, 1], [17, 4]]} - } - }, - { - "id": "highway-area", - "type": "fill", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "$type", "Polygon"], ["!in", "class", "pier"]], - "layout": {"visibility": "visible"}, - "paint": { - "fill-antialias": false, - "fill-color": "hsla(0, 0%, 89%, 0.56)", - "fill-opacity": 0.9, - "fill-outline-color": "#cfcdca" - } - }, - { - "id": "highway-path-steps-casing", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "path"], - ["in", "subclass", "steps"] - ], - "layout": {"line-cap": "butt", "line-join": "round"}, - "paint": { - "line-color": "#cfcdca", - "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[12, 0.5], [13, 1], [14, 2], [20, 9.25]] - } - } - }, - { - "id": "highway-motorway-link-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 12, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "motorway"], - ["==", "ramp", 1] - ], - "layout": {"line-cap": "round", "line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] - } - } - }, - { - "id": "highway-link-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 13, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "trunk", "primary", "secondary", "tertiary"], - ["==", "ramp", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] - } - } - }, - { - "id": "highway-minor-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!=", "brunnel", "tunnel"], - ["in", "class", "minor", "service", "track"] - ], - "layout": {"line-cap": "round", "line-join": "round"}, - "paint": { - "line-color": "#cfcdca", - "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[12, 0.5], [13, 1], [14, 4], [20, 15]] - } - } - }, - { - "id": "highway-secondary-tertiary-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "secondary", "tertiary"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "butt", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": {"base": 1.2, "stops": [[8, 1.5], [20, 17]]} - } - }, - { - "id": "highway-primary-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 5, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "primary"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "butt", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#e9ac77", - "line-opacity": {"stops": [[7, 0], [8, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[7, 0], [8, 0.6], [9, 1.5], [20, 22]] - } - } - }, - { - "id": "highway-trunk-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 5, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "trunk"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "butt", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#e9ac77", - "line-opacity": {"stops": [[5, 0], [6, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[5, 0], [6, 0.6], [7, 1.5], [20, 22]] - } - } - }, - { - "id": "highway-motorway-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 4, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "motorway"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "butt", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#e9ac77", - "line-opacity": {"stops": [[4, 0], [5, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[4, 0], [5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] - } - } - }, - { - "id": "highway-path", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "path"], - ["!=", "subclass", "steps"] - ], - "paint": { - "line-color": "#cba", - "line-dasharray": [1.5, 0.75], - "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} - } - }, - { - "id": "highway-path-steps", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "path"], - ["==", "subclass", "steps"] - ], - "layout": {"line-join": "bevel", "line-cap": "butt"}, - "paint": { - "line-color": "#fff", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] - }, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "highway-motorway-link", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 12, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "motorway"], - ["==", "ramp", 1] - ], - "layout": {"line-cap": "round", "line-join": "round"}, - "paint": { - "line-color": "#fc8", - "line-width": { - "base": 1.2, - "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] - } - } - }, - { - "id": "highway-link", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 13, - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "trunk", "primary", "secondary", "tertiary"], - ["==", "ramp", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#fea", - "line-width": { - "base": 1.2, - "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] - } - } - }, - { - "id": "highway-minor", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!=", "brunnel", "tunnel"], - ["in", "class", "minor", "service", "track"] - ], - "layout": {"line-cap": "round", "line-join": "round"}, - "paint": { - "line-color": "#fff", - "line-opacity": 1, - "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} - } - }, - { - "id": "highway-secondary-tertiary", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "secondary", "tertiary"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#fea", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [8, 0.5], [20, 13]]} - } - }, - { - "id": "highway-primary", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "primary"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#fea", - "line-width": {"base": 1.2, "stops": [[8.5, 0], [9, 0.5], [20, 18]]} - } - }, - { - "id": "highway-trunk", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!in", "brunnel", "bridge", "tunnel"], - ["in", "class", "trunk"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#fea", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} - } - }, - { - "id": "highway-motorway", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 5, - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "motorway"], - ["!=", "ramp", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "#fc8", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} - } - }, - { - "id": "railway-transit", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "class", "transit"], - ["!in", "brunnel", "tunnel"] - ], - "layout": {"visibility": "visible"}, - "paint": { - "line-color": "hsla(0, 0%, 73%, 0.77)", - "line-width": {"base": 1.4, "stops": [[14, 0.4], [20, 1]]} - } - }, - { - "id": "railway-transit-hatching", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "class", "transit"], - ["!in", "brunnel", "tunnel"] - ], - "layout": {"visibility": "visible"}, - "paint": { - "line-color": "hsla(0, 0%, 73%, 0.68)", - "line-dasharray": [0.2, 8], - "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 2], [20, 6]]} - } - }, - { - "id": "railway-service", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "class", "rail"], - ["has", "service"] - ], - "paint": { - "line-color": "hsla(0, 0%, 73%, 0.77)", - "line-width": {"base": 1.4, "stops": [[14, 0.4], [20, 1]]} - } - }, - { - "id": "railway-service-hatching", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "class", "rail"], - ["has", "service"] - ], - "layout": {"visibility": "visible"}, - "paint": { - "line-color": "hsla(0, 0%, 73%, 0.68)", - "line-dasharray": [0.2, 8], - "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 2], [20, 6]]} - } - }, - { - "id": "railway", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!has", "service"], - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "rail"] - ], - "paint": { - "line-color": "#bbb", - "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} - } - }, - { - "id": "railway-hatching", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["!has", "service"], - ["!in", "brunnel", "bridge", "tunnel"], - ["==", "class", "rail"] - ], - "paint": { - "line-color": "#bbb", - "line-dasharray": [0.2, 8], - "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 3], [20, 8]]} - } - }, - { - "id": "bridge-motorway-link-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["==", "class", "motorway"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[12, 1], [13, 3], [14, 4], [20, 19]] - } - } - }, - { - "id": "bridge-link-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["in", "class", "trunk", "primary", "secondary", "tertiary"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[12, 1], [13, 3], [14, 4], [20, 19]] - } - } - }, - { - "id": "bridge-secondary-tertiary-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["in", "class", "secondary", "tertiary"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[5, 0.4], [7, 0.6], [8, 1.5], [20, 21]] - } - } - }, - { - "id": "bridge-trunk-primary-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["in", "class", "primary", "trunk"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "hsl(28, 76%, 67%)", - "line-width": { - "base": 1.2, - "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 26]] - } - } - }, - { - "id": "bridge-motorway-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["==", "class", "motorway"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#e9ac77", - "line-width": { - "base": 1.2, - "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 26]] - } - } - }, - { - "id": "bridge-minor-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "bridge"], - ["in", "class", "minor", "service", "track"] - ], - "layout": {"line-cap": "butt", "line-join": "round"}, - "paint": { - "line-color": "#cfcdca", - "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, - "line-width": { - "base": 1.2, - "stops": [[12, 0.5], [13, 1], [14, 6], [20, 24]] - } - } - }, - { - "id": "bridge-path-casing", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "bridge"], - ["==", "class", "path"] - ], - "paint": { - "line-color": "#cfcdca", - "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 18]]} - } - }, - { - "id": "bridge-path-steps", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "bridge"], - ["==", "class", "path"], - ["==", "subclass", "steps"] - ], - "layout": {"line-join": "round", "line-cap": "butt"}, - "paint": { - "line-color": "#fff", - "line-opacity": 1, - "line-width": { - "base": 1.2, - "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] - }, - "line-dasharray": [0.5, 0.25] - } - }, - { - "id": "bridge-path", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "bridge"], - ["==", "class", "path"], - ["!=", "subclass", "steps"] - ], - "paint": { - "line-color": "#cba", - "line-dasharray": [1.5, 0.75], - "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} - } - }, - { - "id": "bridge-motorway-link", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["==", "class", "motorway"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fc8", - "line-width": { - "base": 1.2, - "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] - } - } - }, - { - "id": "bridge-link", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["in", "class", "trunk", "primary", "secondary", "tertiary"], - ["==", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fea", - "line-width": { - "base": 1.2, - "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] - } - } - }, - { - "id": "bridge-minor", - "type": "line", - "metadata": {"mapbox:group": "1444849345966.4436"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "$type", "LineString"], - ["==", "brunnel", "bridge"], - ["in", "class", "minor", "service", "track"] - ], - "layout": {"line-cap": "round", "line-join": "round"}, - "paint": { - "line-color": "#fff", - "line-opacity": 1, - "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} - } - }, - { - "id": "bridge-secondary-tertiary", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["in", "class", "secondary", "tertiary"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fea", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [8, 0.5], [20, 13]]} - } - }, - { - "id": "bridge-trunk-primary", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["in", "class", "primary", "trunk"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fea", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} - } - }, - { - "id": "bridge-motorway", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": [ - "all", - ["==", "brunnel", "bridge"], - ["==", "class", "motorway"], - ["!=", "ramp", 1] - ], - "layout": {"line-join": "round"}, - "paint": { - "line-color": "#fc8", - "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} - } - }, - { - "id": "bridge-railway", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], - "paint": { - "line-color": "#bbb", - "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} - } - }, - { - "id": "bridge-railway-hatching", - "type": "line", - "metadata": {"mapbox:group": "1444849334699.1902"}, - "source": "dedicatedcode", - "source-layer": "transportation", - "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], - "paint": { - "line-color": "#bbb", - "line-dasharray": [0.2, 8], - "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 3], [20, 8]]} - } - }, - { - "id": "cablecar", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 13, - "filter": ["==", "subclass", "cable_car"], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "hsl(0, 0%, 70%)", - "line-width": {"base": 1, "stops": [[11, 1], [19, 2.5]]} - } - }, - { - "id": "cablecar-dash", - "type": "line", - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 13, - "filter": ["==", "subclass", "cable_car"], - "layout": {"line-cap": "round", "visibility": "visible"}, - "paint": { - "line-color": "hsl(0, 0%, 70%)", - "line-dasharray": [2, 3], - "line-width": {"base": 1, "stops": [[11, 3], [19, 5.5]]} - } - }, - { - "id": "boundary-land-level-4", - "type": "line", - "source": "dedicatedcode", - "source-layer": "boundary", - "minzoom": 2, - "filter": [ - "all", - [">=", "admin_level", 3], - ["<=", "admin_level", 8], - ["!=", "maritime", 1] - ], - "layout": {"line-join": "round", "visibility": "visible"}, - "paint": { - "line-color": "#9e9cab", - "line-dasharray": [3, 1, 1, 1], - "line-width": {"base": 1.4, "stops": [[4, 0.4], [5, 1], [12, 3]]} - } - }, - { - "id": "boundary-land-level-2", - "type": "line", - "source": "dedicatedcode", - "source-layer": "boundary", - "filter": [ - "all", - ["==", "admin_level", 2], - ["!=", "maritime", 1], - ["!=", "disputed", 1] - ], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "hsl(248, 7%, 66%)", - "line-width": { - "base": 1, - "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] - } - } - }, - { - "id": "boundary-land-disputed", - "type": "line", - "source": "dedicatedcode", - "source-layer": "boundary", - "filter": ["all", ["!=", "maritime", 1], ["==", "disputed", 1]], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "hsl(248, 7%, 70%)", - "line-dasharray": [1, 3], - "line-width": { - "base": 1, - "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] - } - } - }, - { - "id": "boundary-water", - "type": "line", - "source": "dedicatedcode", - "source-layer": "boundary", - "minzoom": 4, - "filter": ["all", ["in", "admin_level", 2, 4], ["==", "maritime", 1]], - "layout": { - "line-cap": "round", - "line-join": "round", - "visibility": "visible" - }, - "paint": { - "line-color": "rgba(154, 189, 214, 1)", - "line-opacity": {"stops": [[6, 0.6], [10, 1]]}, - "line-width": { - "base": 1, - "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] - } - } - }, - { - "id": "waterway-name", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "waterway", - "minzoom": 13, - "filter": ["all", ["==", "$type", "LineString"], ["has", "name"]], - "layout": { - "symbol-placement": "line", - "symbol-spacing": 350, - "text-field": "{name:latin} {name:nonlatin}", - "text-font": ["Noto Sans Italic"], - "text-letter-spacing": 0.2, - "text-max-width": 5, - "text-rotation-alignment": "map", - "text-size": 14 - }, - "paint": { - "text-color": "#74aee9", - "text-halo-color": "rgba(255,255,255,0.7)", - "text-halo-width": 1.5 - } - }, - { - "id": "water-name-lakeline", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "water_name", - "filter": ["==", "$type", "LineString"], - "layout": { - "symbol-placement": "line", - "symbol-spacing": 350, - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Italic"], - "text-letter-spacing": 0.2, - "text-max-width": 5, - "text-rotation-alignment": "map", - "text-size": 14 - }, - "paint": { - "text-color": "#74aee9", - "text-halo-color": "rgba(255,255,255,0.7)", - "text-halo-width": 1.5 - } - }, - { - "id": "water-name-ocean", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "water_name", - "filter": ["all", ["==", "$type", "Point"], ["==", "class", "ocean"]], - "layout": { - "symbol-placement": "point", - "symbol-spacing": 350, - "text-field": "{name:latin}", - "text-font": ["Noto Sans Italic"], - "text-letter-spacing": 0.2, - "text-max-width": 5, - "text-rotation-alignment": "map", - "text-size": 14 - }, - "paint": { - "text-color": "#74aee9", - "text-halo-color": "rgba(255,255,255,0.7)", - "text-halo-width": 1.5 - } - }, - { - "id": "water-name-other", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "water_name", - "filter": ["all", ["==", "$type", "Point"], ["!in", "class", "ocean"]], - "layout": { - "symbol-placement": "point", - "symbol-spacing": 350, - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Italic"], - "text-letter-spacing": 0.2, - "text-max-width": 5, - "text-rotation-alignment": "map", - "text-size": {"stops": [[0, 10], [6, 14]]}, - "visibility": "visible" - }, - "paint": { - "text-color": "#74aee9", - "text-halo-color": "rgba(255,255,255,0.7)", - "text-halo-width": 1.5 - } - }, - { - "id": "road_oneway", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 15, - "filter": [ - "all", - ["==", "oneway", 1], - [ - "in", - "class", - "motorway", - "trunk", - "primary", - "secondary", - "tertiary", - "minor", - "service" - ] - ], - "layout": { - "icon-image": "oneway", - "icon-padding": 2, - "icon-rotate": 90, - "icon-rotation-alignment": "map", - "icon-size": {"stops": [[15, 0.5], [19, 1]]}, - "symbol-placement": "line", - "symbol-spacing": 75 - }, - "paint": {"icon-opacity": 0.5} - }, - { - "id": "road_oneway_opposite", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation", - "minzoom": 15, - "filter": [ - "all", - ["==", "oneway", -1], - [ - "in", - "class", - "motorway", - "trunk", - "primary", - "secondary", - "tertiary", - "minor", - "service" - ] - ], - "layout": { - "icon-image": "oneway", - "icon-padding": 2, - "icon-rotate": -90, - "icon-rotation-alignment": "map", - "icon-size": {"stops": [[15, 0.5], [19, 1]]}, - "symbol-placement": "line", - "symbol-spacing": 75 - }, - "paint": {"icon-opacity": 0.5} - }, - { - "id": "poi-level-3", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "poi", - "minzoom": 16, - "filter": [ - "all", - ["==", "$type", "Point"], - [">=", "rank", 25], - ["any", ["!has", "level"], ["==", "level", 0]] - ], - "layout": { - "icon-image": "{class}_11", - "text-anchor": "top", - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 9, - "text-offset": [0, 0.6], - "text-padding": 2, - "text-size": 12, - "visibility": "visible" - }, - "paint": { - "text-color": "#666", - "text-halo-blur": 0.5, - "text-halo-color": "#ffffff", - "text-halo-width": 1 - } - }, - { - "id": "poi-level-2", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "poi", - "minzoom": 15, - "filter": [ - "all", - ["==", "$type", "Point"], - ["<=", "rank", 24], - [">=", "rank", 15], - ["any", ["!has", "level"], ["==", "level", 0]] - ], - "layout": { - "icon-image": "{class}_11", - "text-anchor": "top", - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 9, - "text-offset": [0, 0.6], - "text-padding": 2, - "text-size": 12, - "visibility": "visible" - }, - "paint": { - "text-color": "#666", - "text-halo-blur": 0.5, - "text-halo-color": "#ffffff", - "text-halo-width": 1 - } - }, - { - "id": "poi-level-1", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "poi", - "minzoom": 14, - "filter": [ - "all", - ["==", "$type", "Point"], - ["<=", "rank", 14], - ["has", "name"], - ["any", ["!has", "level"], ["==", "level", 0]] - ], - "layout": { - "icon-image": "{class}_11", - "text-anchor": "top", - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 9, - "text-offset": [0, 0.6], - "text-padding": 2, - "text-size": 12, - "visibility": "visible" - }, - "paint": { - "text-color": "#666", - "text-halo-blur": 0.5, - "text-halo-color": "#ffffff", - "text-halo-width": 1 - } - }, - { - "id": "poi-railway", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "poi", - "minzoom": 13, - "filter": [ - "all", - ["==", "$type", "Point"], - ["has", "name"], - ["==", "class", "railway"], - ["==", "subclass", "station"] - ], - "layout": { - "icon-allow-overlap": false, - "icon-ignore-placement": false, - "icon-image": "{class}_11", - "icon-optional": false, - "text-allow-overlap": false, - "text-anchor": "top", - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-ignore-placement": false, - "text-max-width": 9, - "text-offset": [0, 0.6], - "text-optional": true, - "text-padding": 2, - "text-size": 12 - }, - "paint": { - "text-color": "#666", - "text-halo-blur": 0.5, - "text-halo-color": "#ffffff", - "text-halo-width": 1 - } - }, - { - "id": "highway-name-path", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation_name", - "minzoom": 15.5, - "filter": ["==", "class", "path"], - "layout": { - "symbol-placement": "line", - "text-field": "{name:latin} {name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-rotation-alignment": "map", - "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} - }, - "paint": { - "text-color": "hsl(30, 23%, 62%)", - "text-halo-color": "#f8f4f0", - "text-halo-width": 0.5 - } - }, - { - "id": "highway-name-minor", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation_name", - "minzoom": 15, - "filter": [ - "all", - ["==", "$type", "LineString"], - ["in", "class", "minor", "service", "track"] - ], - "layout": { - "symbol-placement": "line", - "text-field": "{name:latin} {name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-rotation-alignment": "map", - "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} - }, - "paint": { - "text-color": "#765", - "text-halo-blur": 0.5, - "text-halo-width": 1 - } - }, - { - "id": "highway-name-major", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation_name", - "minzoom": 12.2, - "filter": ["in", "class", "primary", "secondary", "tertiary", "trunk"], - "layout": { - "symbol-placement": "line", - "text-field": "{name:latin} {name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-rotation-alignment": "map", - "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} - }, - "paint": { - "text-color": "#765", - "text-halo-blur": 0.5, - "text-halo-width": 1 - } - }, - { - "id": "highway-shield", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation_name", - "minzoom": 8, - "filter": [ - "all", - ["<=", "ref_length", 6], - ["==", "$type", "LineString"], - ["!in", "network", "us-interstate", "us-highway", "us-state"] - ], - "layout": { - "icon-image": "road_{ref_length}", - "icon-rotation-alignment": "viewport", - "icon-size": 1, - "symbol-placement": {"base": 1, "stops": [[10, "point"], [11, "line"]]}, - "symbol-spacing": 200, - "text-field": "{ref}", - "text-font": ["Noto Sans Regular"], - "text-rotation-alignment": "viewport", - "text-size": 10 - }, - "paint": {} - }, - { - "id": "highway-shield-us-interstate", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation_name", - "minzoom": 7, - "filter": [ - "all", - ["<=", "ref_length", 6], - ["==", "$type", "LineString"], - ["in", "network", "us-interstate"] - ], - "layout": { - "icon-image": "{network}_{ref_length}", - "icon-rotation-alignment": "viewport", - "icon-size": 1, - "symbol-placement": { - "base": 1, - "stops": [[7, "point"], [7, "line"], [8, "line"]] - }, - "symbol-spacing": 200, - "text-field": "{ref}", - "text-font": ["Noto Sans Regular"], - "text-rotation-alignment": "viewport", - "text-size": 10 - }, - "paint": {"text-color": "rgba(0, 0, 0, 1)"} - }, - { - "id": "highway-shield-us-other", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "transportation_name", - "minzoom": 9, - "filter": [ - "all", - ["<=", "ref_length", 6], - ["==", "$type", "LineString"], - ["in", "network", "us-highway", "us-state"] - ], - "layout": { - "icon-image": "{network}_{ref_length}", - "icon-rotation-alignment": "viewport", - "icon-size": 1, - "symbol-placement": {"base": 1, "stops": [[10, "point"], [11, "line"]]}, - "symbol-spacing": 200, - "text-field": "{ref}", - "text-font": ["Noto Sans Regular"], - "text-rotation-alignment": "viewport", - "text-size": 10 - }, - "paint": {"text-color": "rgba(0, 0, 0, 1)"} - }, - { - "id": "airport-label-major", - "type": "symbol", - "source": "dedicatedcode", - "source-layer": "aerodrome_label", - "minzoom": 10, - "filter": ["all", ["has", "iata"]], - "layout": { - "icon-image": "airport_11", - "icon-size": 1, - "text-anchor": "top", - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 9, - "text-offset": [0, 0.6], - "text-optional": true, - "text-padding": 2, - "text-size": 12, - "visibility": "visible" - }, - "paint": { - "text-color": "#666", - "text-halo-blur": 0.5, - "text-halo-color": "#ffffff", - "text-halo-width": 1 - } - }, - { - "id": "building-3d", - "type": "fill-extrusion", - "source": "dedicatedcode", - "source-layer": "building", - "minzoom": 1, - "maxzoom": 24, - "layout": { - "visibility": "visible" - }, - "paint": { - "fill-extrusion-color": "rgba(209, 209, 209, 1)", - "fill-extrusion-height": { - "property": "render_height", - "type": "identity" - }, - "fill-extrusion-base": { - "property": "render_min_height", - "type": "identity" - }, - "fill-extrusion-opacity": 1, - "fill-extrusion-vertical-gradient": false, - "fill-extrusion-translate-anchor": "viewport" - } - }, - { - "id": "place-other", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": [ - "!in", - "class", - "city", - "town", - "village", - "state", - "country", - "continent" - ], - "layout": { - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Bold"], - "text-letter-spacing": 0.1, - "text-max-width": 9, - "text-size": {"base": 1.2, "stops": [[12, 10], [15, 14]]}, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#633", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1.2 - } - }, - { - "id": "place-village", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": ["==", "class", "village"], - "layout": { - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 8, - "text-size": {"base": 1.2, "stops": [[10, 12], [15, 22]]}, - "visibility": "visible" - }, - "paint": { - "text-color": "#333", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1.2 - } - }, - { - "id": "place-town", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": ["==", "class", "town"], - "layout": { - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 8, - "text-size": {"base": 1.2, "stops": [[10, 14], [15, 24]]}, - "visibility": "visible" - }, - "paint": { - "text-color": "#333", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1.2 - } - }, - { - "id": "place-city", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": ["all", ["!=", "capital", 2], ["==", "class", "city"]], - "layout": { - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 8, - "text-size": {"base": 1.2, "stops": [[7, 14], [11, 24]]}, - "visibility": "visible" - }, - "paint": { - "text-color": "#333", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1.2 - } - }, - { - "id": "place-city-capital", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": ["all", ["==", "capital", 2], ["==", "class", "city"]], - "layout": { - "icon-image": "star_11", - "icon-size": 0.8, - "text-anchor": "left", - "text-field": "{name:latin}\n{name:nonlatin}", - "text-font": ["Noto Sans Regular"], - "text-max-width": 8, - "text-offset": [0.4, 0], - "text-size": {"base": 1.2, "stops": [[7, 14], [11, 24]]}, - "visibility": "visible" - }, - "paint": { - "text-color": "#333", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1.2 - } - }, - { - "id": "place-state", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": ["in", "class", "state"], - "layout": { - "text-field": "{name:latin}", - "text-font": ["Noto Sans Bold"], - "text-letter-spacing": 0.1, - "text-max-width": 9, - "text-size": {"base": 1.2, "stops": [[12, 10], [15, 14]]}, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#633", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1.2 - } - }, - { - "id": "place-country-other", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": [ - "all", - ["==", "class", "country"], - [">=", "rank", 3], - ["!has", "iso_a2"] - ], - "layout": { - "text-field": "{name:latin}", - "text-font": ["Noto Sans Italic"], - "text-max-width": 6.25, - "text-size": {"stops": [[3, 11], [7, 17]]}, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#334", - "text-halo-blur": 1, - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 2 - } - }, - { - "id": "place-country-3", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": [ - "all", - ["==", "class", "country"], - [">=", "rank", 3], - ["has", "iso_a2"] - ], - "layout": { - "text-field": "{name:latin}", - "text-font": ["Noto Sans Bold"], - "text-max-width": 6.25, - "text-size": {"stops": [[3, 11], [7, 17]]}, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#334", - "text-halo-blur": 1, - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 2 - } - }, - { - "id": "place-country-2", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": [ - "all", - ["==", "class", "country"], - ["==", "rank", 2], - ["has", "iso_a2"] - ], - "layout": { - "text-field": "{name:latin}", - "text-font": ["Noto Sans Bold"], - "text-max-width": 6.25, - "text-size": {"stops": [[2, 11], [5, 17]]}, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#334", - "text-halo-blur": 1, - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 2 - } - }, - { - "id": "place-country-1", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "filter": [ - "all", - ["==", "class", "country"], - ["==", "rank", 1], - ["has", "iso_a2"] - ], - "layout": { - "text-field": "{name:latin}", - "text-font": ["Noto Sans Bold"], - "text-max-width": 6.25, - "text-size": {"stops": [[1, 11], [4, 17]]}, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#334", - "text-halo-blur": 1, - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 2 - } - }, - { - "id": "place-continent", - "type": "symbol", - "metadata": {"mapbox:group": "1444849242106.713"}, - "source": "dedicatedcode", - "source-layer": "place", - "maxzoom": 1, - "filter": ["==", "class", "continent"], - "layout": { - "text-field": "{name:latin}", - "text-font": ["Noto Sans Bold"], - "text-max-width": 6.25, - "text-size": 14, - "text-transform": "uppercase", - "visibility": "visible" - }, - "paint": { - "text-color": "#334", - "text-halo-blur": 1, - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 2 - } - } - ], - "id": "bright" -} \ No newline at end of file diff --git a/src/main/resources/templates/fragments/configuration-preview.html b/src/main/resources/templates/fragments/configuration-preview.html index 6f605ae35..c9b8a9f11 100644 --- a/src/main/resources/templates/fragments/configuration-preview.html +++ b/src/main/resources/templates/fragments/configuration-preview.html @@ -124,7 +124,7 @@

Visit Merging

const tilesUrl = window.userSettings.tiles.service; const tilesAttribution = window.userSettings.tiles.attribution; - const tileLayer = window.userSettings?.preferColoredMap ? L.tileLayer : L.tileLayer.grayscale; + const tileLayer = L.tileLayer.grayscale; tileLayer(tilesUrl, { maxZoom: 19, attribution: tilesAttribution @@ -146,7 +146,7 @@

Visit Merging

const tilesUrl = window.userSettings.tiles.service; const tilesAttribution = window.userSettings.tiles.attribution; - const tileLayer = window.userSettings?.preferColoredMap ? L.tileLayer : L.tileLayer.grayscale; + const tileLayer = L.tileLayer.grayscale; tileLayer(tilesUrl, { maxZoom: 19, attribution: tilesAttribution diff --git a/src/main/resources/templates/fragments/index/metadata.html b/src/main/resources/templates/fragments/index/metadata.html new file mode 100644 index 000000000..fc5c00e4e --- /dev/null +++ b/src/main/resources/templates/fragments/index/metadata.html @@ -0,0 +1,114 @@ + + + + + + Reitti - Your Location Timeline + + + + + + + + + + + + + + + + + + +
+ +

Trip between Mai 15 07:32 - 05:54

+

Visit to Moltkestraße (Mai 15 07:32 - 05:54)

+ + +
+ + + diff --git a/src/main/resources/templates/fragments/main-navigation.html b/src/main/resources/templates/fragments/main-navigation.html index 201dec6d8..7f5f0d796 100644 --- a/src/main/resources/templates/fragments/main-navigation.html +++ b/src/main/resources/templates/fragments/main-navigation.html @@ -1,15 +1,16 @@ - + + +
+
No custom styles yet
+
+
+
Style name
+
+ Vector + · + Tile Template + URL + · Shared + · Proxied +
+
+
+ + +
+
+
+ + + + + +
+ Edit Custom Style + Add Custom Style +
+ +
+ + +
+ +
+ +
+
+ +

Tile requests for this style are fetched through Reitti.

+
+ +
+
+
+ +

Other users can select this style, but only you can edit it.

+
+ +
+ + + +
+ + +
+ + + +
+
+ Map Type +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ Advanced Options +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Style Input +
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ Tile Settings +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ Source Input +
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + diff --git a/src/main/resources/templates/fragments/settings-navigation.html b/src/main/resources/templates/fragments/settings-navigation.html index 1dd5183dd..68fe1641f 100644 --- a/src/main/resources/templates/fragments/settings-navigation.html +++ b/src/main/resources/templates/fragments/settings-navigation.html @@ -53,7 +53,13 @@ class="settings-nav-item" th:classappend="${activeSection == 'transportation-modes'} ? 'active' : ''" th:title="#{settings.transportation-modes.description}" - th:text="#{settings.transportation-modes}">Transportion Modes + th:text="#{settings.transportation-modes}">Transportation Modes + + Map Styles Integrations + Devices Add New Reitti In
-
-
diff --git a/src/main/resources/templates/fragments/timeline.html b/src/main/resources/templates/fragments/timeline.html index cb6369ba2..7c93d979b 100644 --- a/src/main/resources/templates/fragments/timeline.html +++ b/src/main/resources/templates/fragments/timeline.html @@ -13,17 +13,55 @@ loading ... -
-
+
+ +
Username
-
+ +
+ + +
+
+
+ +
Username
+
+
-
- - -
- - - - Trip + +
+ +
+
+
+
Activity Overview
+
- -
- Home +
+
+
+
Visited Places
+
1
+
+
+
+
Journeys
+
1
+
- - +
+
+ +
+ +
+
+ + +
+
+ Place Name +
+ + +
+
+ + +
+ Trip +
+ + +
+
+
+ +
+ Home +
+ + Duration: 1h 30m @@ -77,30 +400,26 @@ Distance: 5.2 km -
+
by foot -
- -
+ +
09:00 - 10:30 - 09:00 - 10:30 -
- -
+ 09:00 - 10:30 +
+
+
+ +
- diff --git a/src/main/resources/templates/fragments/trip-edit.html b/src/main/resources/templates/fragments/trip-edit.html index a624f7829..055035d86 100644 --- a/src/main/resources/templates/fragments/trip-edit.html +++ b/src/main/resources/templates/fragments/trip-edit.html @@ -30,12 +30,8 @@
-
+
by foot -
diff --git a/src/main/resources/templates/fragments/user-management.html b/src/main/resources/templates/fragments/user-management.html index 7afad155e..239605bd9 100644 --- a/src/main/resources/templates/fragments/user-management.html +++ b/src/main/resources/templates/fragments/user-management.html @@ -324,16 +324,6 @@

Color Theme

-
- - -

When enabled, the map will be - displayed in full color. When disabled, the map will be shown in grayscale.

-
-

Home Location

Set your home location. This location will be displayed when no data is available for the selected date.

@@ -380,106 +370,6 @@

Home Location

th:text="#{form.cancel}">Cancel
- @@ -38,6 +39,60 @@ + + +
@@ -47,23 +102,30 @@
Ready
+
-
+
Loading...
+ +
@@ -90,7 +158,8 @@ - + + @@ -148,6 +217,8 @@ window.userSettings = /*[[${userSettings}]]*/ {} + window.reittiCustomMapStyles = /*[(${mapStylesJson})]*/ []; + window.reittiActiveMapStyleId = /*[[${activeMapStyleId}]]*/ null; let gpsDataManagers = []; let currentDateRange = null; @@ -169,24 +240,27 @@ const timeline = document.querySelector('.timeline'); const datePicker = document.getElementById('date-picker-container'); const fabContainer = document.getElementById('fab-container'); + const autoUpdateUserSelection = document.getElementById('auto-update-user-selection'); if (autoUpdateMode) { timeline.classList.add('hidden'); datePicker.classList.add('hidden'); fabContainer.classList.add('hidden'); + autoUpdateUserSelection.classList.remove('hidden'); if (window.horizontalDatePicker) { window.horizontalDatePicker.setSelectedRange(getCurrentLocalDate(), null); } timelineControl.hide(); - mapRenderer.enableAvatars(); } else { timeline.classList.remove('hidden'); datePicker.classList.remove('hidden'); fabContainer.classList.remove('hidden'); - mapRenderer.disableAvatars(); + autoUpdateUserSelection.classList.add('hidden'); } + updateAvatarsVisibility(); handleAutoTodaySelectionTimer(autoUpdateMode) }) + const mapControls = new MapControls('map-controls-section'); const mapRenderer = new MapRenderer('new-map', userSettings, createViewState()); @@ -218,6 +292,36 @@ } }; + function updateAvatarsVisibility() { + const isAutoUpdate = Boolean(liveModeController && liveModeController.isActive()); + + if (isAutoUpdate) { + mapRenderer.enableAvatars(); + return; + } + + const settingEnabled = localStorage.getItem('showAvatars') !== 'false'; + if (!settingEnabled) { + mapRenderer.disableAvatars(); + return; + } + + // Normal mode: only show avatars if the selected date is today + const today = getCurrentLocalDate(); // e.g. "2023-06-01" + const selectedStart = currentDateRange?.startDate; + const isSingleToday = selectedStart === today && (!currentDateRange?.endDate || currentDateRange.endDate === today); + + if (!isSingleToday) { + mapRenderer.disableAvatars(); + return; + } + + const todayStart = Math.floor(new Date(today + 'T00:00:00').getTime()); + const todayEnd = todayStart + 86400000; // 24 hours + + mapRenderer.enableAvatars(todayStart, todayEnd); + } + function startAnimation() { isPlaying = true; playBtn.innerHTML = ''; // Change icon to Pause @@ -249,38 +353,55 @@ } } + let linearProgress = 0; + function animate(now) { if (!isPlaying) return; if (lastFrameTime === null) { - lastFrameTime = now; // Capture the start time - animationId = requestAnimationFrame(animate); // Request next frame - return; // Exit early without moving the slider + lastFrameTime = now; + animationId = requestAnimationFrame(animate); + return; } - // 1. Calculate how much real time has passed since the last frame - // 'now' is provided automatically by requestAnimationFrame - const deltaTime = (now - lastFrameTime) / 1000; // convert ms to seconds - lastFrameTime = now; // Update for the next frame - // 2. Logic to prevent "jumps" (e.g., if the user changes tabs) + const deltaTime = (now - lastFrameTime) / 1000; // seconds + lastFrameTime = now; + + // Prevent large jumps (e.g., tab switching) if (deltaTime > 0.1) { animationId = requestAnimationFrame(animate); return; } - // 3. Advance the slider - let step = timeMultiplier * deltaTime; - let newValue = parseFloat(timelineControl.getOffset()) + step; + const manager = gpsDataManagers[0]; + + // Calculate linear progress (0 → 1) over the full loop - // 4. Loop logic - if (newValue > parseFloat(timelineControl.getMaxOffset())) { - newValue = 0; + const totalLoopTime = parseFloat(timelineControl.getMaxOffset()) / timeMultiplier; + const activityPerSecond = manager.totalActivity / totalLoopTime; + linearProgress += (deltaTime * activityPerSecond) / manager.totalActivity; + + if (linearProgress >= 1) { + linearProgress -= 1; // loop back to start } - // 5. Update UI and Map - timelineControl.setOffset(newValue); + const warp = document.getElementById('speed-selector').value === 'adaptive'; + let adaptedOffset; + if (manager && typeof manager.getDisplayTimestamp === 'function') { + const minTs = manager.minTimestamp; + if (timelineControl.aggregate) { + // aggregate mode: timestamp is seconds-of-day (0–86400) + adaptedOffset = linearProgress * 86400; + } else { + const adaptedTimestamp = manager.getDisplayTimestamp(linearProgress, warp, timelineControl.aggregate); + adaptedOffset = adaptedTimestamp - minTs; + } + } else { + adaptedOffset = linearProgress * parseFloat(timelineControl.getMaxOffset()); + } + // Apply the adapted offset to the slider and the map + timelineControl.setOffset(adaptedOffset); mapRenderer.updateViewState(createViewState([])); - // 6. Request the next frame animationId = requestAnimationFrame(animate); } @@ -318,6 +439,53 @@ } } + function loadGpsData(requestedStart, requestedEnd) { + const RENDER_CHUNK_SIZE = 50000; + const completedManagers = new Set(); + + gpsDataManagers.forEach((gpsDataManager, index) => { + let lastRenderedPointCount = 0; + gpsDataManager.load(requestedStart, requestedEnd, (current, total, stage) => { + + switch (stage) { + case 'metadata': + updateStatus("Fetching metadata...", 10, 100); + + break; + case 'streaming': + updateStatus("Loading point {0} / {1}", current, total); + if (current - lastRenderedPointCount >= RENDER_CHUNK_SIZE) { + lastRenderedPointCount = current; + requestAnimationFrame(() => { + mapRenderer._refitBounds(); + mapRenderer.updateViewState(createViewState()); + }); + } + break; + case 'bundling': + updateStatus("Optimizing routes: {0} / {1} points", current, total); + break; + case 'complete': + lastRenderedPointCount = 0; + const aggregateToggle = document.getElementById('aggregate-toggle'); + timelineControl.setup({ + aggregate: aggregateToggle ? aggregateToggle.checked : false, + minTimestamp: gpsDataManager.minTimestamp, + maxTimestamp: gpsDataManager.maxTimestamp, + }) + calculateTimelineMultiplier(); + updateStatus("Finished processing {1} points", current, total); + mapRenderer.updateViewState(createViewState()); + completedManagers.add(index); + if (completedManagers.size === gpsDataManagers.length) { + mapRenderer.finishedLoading(); + } + + } + }); + }); + } + function fadeOutOverlay(overlay) { setTimeout(() => { overlay.style.transition = 'opacity 1s ease'; @@ -360,28 +528,122 @@ } } + function showUser(userHeader) { + mapRenderer.setSelectedManager(userHeader.getAttribute('data-user-id')); + } + + function toggleDeviceMenu(btn) { + const container = btn.closest('.device-toggle-container'); + const menu = container.querySelector('.device-menu'); + document.querySelectorAll('.device-menu:not(.hidden)').forEach(m => { + if (m !== menu) { + m.classList.add('hidden'); + } + }); + menu.classList.toggle('hidden'); + } + + function enableDeviceGpsDataManager(userId, deviceId, displayName, color, avatarUrl, avatarFallback, showAvatar, metaDataUrl, streamUrl) { + const config = { + id: `${userId}_${deviceId}`, + respectBounds: true, + color: color, + avatarUrl: avatarUrl, + avatarFallback: avatarFallback, + showAvatar: showAvatar, + displayName: displayName, + map: { + metaDataUrl: metaDataUrl, + streamUrl: streamUrl, + }, + continuous: false + }; + gpsDataManagers.push(new GpsDataManager(config, window.userSettings, getUserTimezone())); + } + + function restoreDeviceVisibility() { + document.querySelectorAll('.device-header').forEach(checkbox => { + const userId = checkbox.dataset.userId; + const deviceId = checkbox.dataset.deviceId; + const displayName = checkbox.dataset.displayName; + const metaDataUrl = checkbox.dataset.deviceMetadataUrl; + const streamUrl = checkbox.dataset.deviceStreamUrl; + const color = checkbox.dataset.deviceColor; + const avatarUrl = checkbox.dataset.avatarUrl; + const avatarFallback = checkbox.dataset.avatarFallback; + const showAvatar = checkbox.dataset.showAvatar; + enableDeviceGpsDataManager(userId, deviceId, displayName, color, avatarUrl, avatarFallback, showAvatar, metaDataUrl, streamUrl); + }); + } + + function followUser(btn) { + const userHeader = btn.closest('.user-header'); + const rawUserId = userHeader.getAttribute('data-user-id'); + const badge = userHeader.querySelector('.user-follow-badge'); + const wasSelected = badge.classList.contains('active'); + + // Deselect all badges + document.querySelectorAll('.user-header .user-follow-badge').forEach(element => { + element.classList.remove('active'); + }); + + // Uncheck all checkboxes + document.querySelectorAll('.user-follow-checkbox').forEach(cb => { + cb.checked = false; + }); + + if (!wasSelected) { + // Check every checkbox that belongs to this user + document.querySelectorAll(`.user-follow-checkbox[data-user-id="${rawUserId}"]`).forEach(cb => { + cb.checked = true; + }); + const followedBadge = document.querySelectorAll(`.user-header[data-user-id="${userHeader.dataset.userId}"] .user-follow-badge`); + // Activate all badges for this user + followedBadge.forEach(badge => badge.classList.add('active')); + + mapRenderer.setSelectedManager(rawUserId); + localStorage.setItem('followedUser', rawUserId); + } else { + mapRenderer.setSelectedManager(null); + localStorage.removeItem('followedUser'); + } + } + + function selectDevice(userHeader) { + document.querySelectorAll('.user-header').forEach(header => { + header.classList.remove('active'); + }); + document.querySelectorAll('.device-header').forEach(header => { + header.classList.remove('active'); + }); + userHeader.classList.add('active'); + + const userId = userHeader.getAttribute('data-user-id'); + const deviceId = userHeader.getAttribute('data-device-id'); + mapRenderer.setSelectedManager(`${userId}_${deviceId}`); + } + function selectUser(userHeader) { - // Remove active class from all user headers document.querySelectorAll('.user-header').forEach(header => { header.classList.remove('active'); }); + document.querySelectorAll('.device-header').forEach(header => { + header.classList.remove('active'); + }); - // Add active class to clicked user header userHeader.classList.add('active'); - // Get the user ID from the clicked header const userId = userHeader.getAttribute('data-user-id'); - // Hide all user timeline sections document.querySelectorAll('.user-timeline-section').forEach(section => { section.classList.remove('active'); }); - // Show the corresponding user timeline section const targetSection = document.querySelector(`.user-timeline-section[data-user-id="${userId}"]`); if (targetSection) { targetSection.classList.add('active'); } + mapRenderer.setSelectedManager(userHeader.getAttribute('data-user-id')); } function updateUrlWithDate(currentDateRange) { @@ -508,60 +770,6 @@ link.setAttribute('href', url.toString()); }); } - function loadTimelineData(requestedStart, requestedEnd) { - console.log('loadTimelineData', requestedStart, requestedEnd); - gpsDataManagers = []; - const RENDER_CHUNK_SIZE = 50000; - const completedManagers = new Set(); - - userConfigs.forEach(config => { - const gpsDataManager = new GpsDataManager(config, window.userSettings, getUserTimezone()); - gpsDataManagers.push(gpsDataManager) - }); - - - mapRenderer.setGpsDataManagers(gpsDataManagers); - gpsDataManagers.forEach((gpsDataManager, index) => { - let lastRenderedPointCount = 0; - gpsDataManager.load(requestedStart, requestedEnd, (current, total, stage) => { - - switch (stage) { - case 'metadata': - updateStatus("Fetching metadata...", 10, 100); - - break; - case 'streaming': - updateStatus("Loading point {0} / {1}", current, total); - if (current - lastRenderedPointCount >= RENDER_CHUNK_SIZE) { - lastRenderedPointCount = current; - requestAnimationFrame(() => { - mapRenderer.updateViewState(createViewState()); - }); - } - break; - case 'bundling': - updateStatus("Optimizing routes: {0} / {1} points", current, total); - break; - case 'complete': - lastRenderedPointCount = 0; - const aggregateToggle = document.getElementById('aggregate-toggle'); - timelineControl.setup({ - aggregate: aggregateToggle ? aggregateToggle.checked : false, - minTimestamp: gpsDataManager.minTimestamp, - maxTimestamp: gpsDataManager.maxTimestamp, - }) - calculateTimelineMultiplier(); - updateStatus("Finished processing {1} points", current, total); - mapRenderer.updateViewState(createViewState()); - completedManagers.add(index); - if (completedManagers.size === gpsDataManagers.length) { - mapRenderer.finishedLoading(); - } - - } - }); - }); - } if (window.userSettings.uiMode === "SHARED_LIVE_MODE_ONLY") { liveModeController.enterLiveMode(); @@ -582,6 +790,24 @@ }; } + function updateFollowedUser() { + if (localStorage.getItem('followedUser')) { + const followedUserId = localStorage.getItem('followedUser'); + const followedBadge = document.querySelectorAll(`.user-header[data-user-id="${followedUserId}"] .user-follow-badge`); + const followedCheckbox = document.querySelectorAll(`.user-header[data-user-id="${followedUserId}"] .user-follow-checkbox`); + + if (followedBadge) { + followedBadge.forEach(badge => badge.classList.add('active')); + followedCheckbox.forEach(cb => cb.checked = 'true'); + + mapRenderer.setSelectedManager(followedUserId); + } else { + console.warn(`User with ID ${followedUserId} not found in the DOM. Will drop it from local storage.`); + localStorage.removeItem('followedUser'); + } + } + } + document.addEventListener('DOMContentLoaded', function () { if (window.userSettings.uiMode !== 'SHARED_LIVE_MODE_ONLY') { settingsMenu.restoreState(); @@ -601,12 +827,14 @@ color: container.dataset.baseColor, avatarUrl: container.dataset.userAvatarUrl, avatarFallback: container.dataset.avatarFallback, + showAvatar: true, displayName: container.dataset.displayName, map: { metaDataUrl: container.dataset.locationMetadataUrl, streamUrl: container.dataset.locationStreamUrl, visitsUrl: container.dataset.processedVisitsUrl - } + }, + continuous: true }; configs.push(config); }); @@ -616,7 +844,8 @@ document.body.addEventListener('htmx:afterSwap', function (event) { if (event.detail.target.classList.contains('timeline-container')) { userConfigs = calculateUserConfigs(); - + gpsDataManagers = []; + updateFollowedUser(); stopAnimation(); const params = getTimelineParams(); @@ -645,7 +874,14 @@ maxTimestamp: params.endDateUtcSeconds, aggregate: aggregateToggle ? aggregateToggle.checked : false }); - loadTimelineData(params.startDateUtcSeconds, params.endDateUtcSeconds); + + userConfigs.forEach(config => { + const gpsDataManager = new GpsDataManager(config, window.userSettings, getUserTimezone()); + gpsDataManagers.push(gpsDataManager) + }); + restoreDeviceVisibility() + mapRenderer.setGpsDataManagers(gpsDataManagers); + loadGpsData(params.startDateUtcSeconds, params.endDateUtcSeconds); // Initialize scroll indicator after timeline is updated if (window.timelineScrollIndicator) { @@ -653,7 +889,7 @@ } window.timelineScrollIndicator = new TimelineScrollIndicator(); window.timelineScrollIndicator.init(); - + updateAvatarsVisibility(); updateEditPlaceLinks(); } }); @@ -800,7 +1036,8 @@ } else { element.innerHTML = `
${t('common.time-range', [dateTimeFormat.format(range.startDate), dateTimeFormat.format(range.endDate)])}
`; } - element.classList.remove('hidden') } + element.classList.remove('hidden') + } } }); @@ -828,7 +1065,7 @@ endDate: window.horizontalDatePicker.formatDate(endDate) }; updateUrlWithDate(currentDateRange); - // Trigger HTMX reload of timeline + updateTodayFabVisibility(); document.body.dispatchEvent(new CustomEvent('dateChanged')); }); @@ -843,7 +1080,7 @@ window.horizontalDatePicker.setSelectedRange(startingDate[0], startingDate[1]); document.getElementById('speed-selector').onchange = function(e) { - if (e.target.value === 'auto') { + if (e.target.value === 'auto' || e.target.value === 'adapative') { calculateTimelineMultiplier(); } else { timeMultiplier = parseInt(e.target.value); @@ -853,16 +1090,29 @@ const settingsBtn = document.getElementById('settings-btn'); if (settingsBtn) { settingsBtn.addEventListener('click', function(e) { - e.stopPropagation(); // Prevent event bubbling + e.stopPropagation(); settingsMenu.open(); }); } + document.addEventListener('click', function (event) { + document.querySelectorAll('.device-menu:not(.hidden)').forEach(menu => { + const container = menu.closest('.device-toggle-container'); + if (!container.contains(event.target)) { + menu.classList.add('hidden'); + } + }); + }); + // Add settings change listener document.addEventListener('settingsChanged', function (event) { const {setting, value} = event.detail; switch (setting) { + case 'mapStyleId': + mapControls.setMapStyleId(value); + mapRenderer.updateViewState(createViewState([])); + break; case 'viewMode': mapRenderer.updateViewState(createViewState([])); break; @@ -883,6 +1133,9 @@ case 'datepickerVisible': document.body.classList.toggle('datepicker-hidden', !value); break; + case 'showAvatars': + updateAvatarsVisibility(); + break; } }); mapRenderer.updateViewState(createViewState([])); @@ -940,6 +1193,48 @@ toggleBtn.classList.add('active'); } } + + function openMetadataOverlay() { + document.getElementById('metadata-overlay').classList.remove('hidden'); + } + function closeMetadataOverlay() { + document.getElementById('metadata-overlay').classList.add('hidden'); + document.getElementById('metadata-overlay-content').innerHTML = ''; + } + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && !document.getElementById('metadata-overlay').classList.contains('hidden')) { + closeMetadataOverlay(); + } + }); + + function addTagChip(tag) { + tag = tag.trim(); + if (!tag) return; + + // Avoid duplicates + const existing = Array.from(document.querySelectorAll('#tags-chips-container .tag-text')) + .map(el => el.textContent); + if (existing.includes(tag)) return; + + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + chip.innerHTML = `${tag}×`; + document.getElementById('tags-chips-container').appendChild(chip); + + updateTagsHidden(); + } + + function removeTagChip(removeBtn) { + removeBtn.parentElement.remove(); + updateTagsHidden(); + } + + function updateTagsHidden() { + const tags = Array.from(document.querySelectorAll('#tags-chips-container .tag-text')) + .map(el => el.textContent); + document.getElementById('tags-hidden').value = tags.join(','); + } + diff --git a/src/main/resources/templates/memories/new.html b/src/main/resources/templates/memories/new.html index 944a757c0..98df3dc2b 100644 --- a/src/main/resources/templates/memories/new.html +++ b/src/main/resources/templates/memories/new.html @@ -112,7 +112,7 @@ window.userSettings = /*[[${userSettings}]]*/ {}; - startDatePicker = new DateTimePicker(document.getElementById('startDatePicker'), { + dateSelection = new DateTimePicker(document.getElementById('startDatePicker'), { timeFormat: getBrowserTimeFormat(), onValidate: validateDates }); @@ -148,7 +148,7 @@ const errorText = document.getElementById('dateErrorText'); if (!startDate || !startTime) { - errorText.textContent = /*t('js.memory.form.date.error.empty')*/ 'Dates cannot be empty'; + errorText.textContent = t('memory.form.date.error.empty'); errorDiv.style.display = 'block'; return false; } @@ -158,7 +158,7 @@ const endTime = document.getElementById('endTime').value; if (!endDate || !endTime) { - errorText.textContent = /*t('js.memory.form.date.error.empty')*/ 'Dates cannot be empty'; + errorText.textContent = t('memory.form.date.error.empty'); errorDiv.style.display = 'block'; return false; } @@ -167,7 +167,7 @@ const endDateTime = new Date(`${endDate}T${endTime}`); if (startDateTime > endDateTime) { - errorText.textContent = /*t('js.memory.form.date.error.end.before.start')*/ 'End date must be on or after start date'; + errorText.textContent = t('memory.form.date.error.end.before.start'); errorDiv.style.display = 'block'; return false; } diff --git a/src/main/resources/templates/memories/view.html b/src/main/resources/templates/memories/view.html index 332bf9a6a..009462e81 100644 --- a/src/main/resources/templates/memories/view.html +++ b/src/main/resources/templates/memories/view.html @@ -454,7 +454,8 @@

Visits in this Journey:

map: { streamUrl: streamUrl, metaDataUrl: metaDataUrl - } + }, + continuous: true }, window.userSettings, getUserTimezone()); mapRenderer.setGpsDataManagers([gpsDataManager]); gpsDataManager.loadFixed((current, total, stage) => { @@ -654,7 +655,8 @@

Process streamUrl: rawLocationUrl, metaDataUrl: metaDataUrl }, - mapDataProviders: additionalProviders + mapDataProviders: additionalProviders, + continuous: true }, window.userSettings, getUserTimezone()); console.log(`Initializing Map: ${elementId} with ${rawLocationUrl}`); mapRenderer.setGpsDataManagers([gpsDataManager]); diff --git a/src/main/resources/templates/settings/api-tokens.html b/src/main/resources/templates/settings/api-tokens.html index 5de7153bf..1251b01af 100644 --- a/src/main/resources/templates/settings/api-tokens.html +++ b/src/main/resources/templates/settings/api-tokens.html @@ -47,17 +47,42 @@

API Tokens

- - - - + + + + - + + + View Device + + + + + + + + + + + + @@ -65,7 +90,7 @@

API Tokens

-
@@ -84,6 +109,7 @@

Recent Token Usages

Token + Device Timestamp Endpoint IP Address @@ -92,6 +118,7 @@

Recent Token Usages

+ @@ -109,4 +136,5 @@

Recent Token Usages

- \ No newline at end of file + + diff --git a/src/main/resources/templates/settings/devices.html b/src/main/resources/templates/settings/devices.html new file mode 100644 index 000000000..df2d4b8f5 --- /dev/null +++ b/src/main/resources/templates/settings/devices.html @@ -0,0 +1,358 @@ + + + + + + Settings - Reitti + + + + + + + + + + + +
+
+
+
+
+

Devices

+ +
+ Device created successfully +
+
+ Error message +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameStatusShow on MapCreatedActions
+ + + Default + + + + Enabled + + + + Disabled + + + + + Yes + + + + No + + + + + + + + +
+
+ +
+ +

Add New Device

+
+ + +
+
+

Color Theme

+

Choose your preferred accent color for the interface.

+ +
+
+ + + + +
+ + + + +
+ +
+
+
+
+ Device Settings +
+
+ + +

Enable or disable this device for tracking.

+
+
+ + +

Enable or disable this device for tracking.

+
+
+ + +

When enabled, the device will show up as an avatar on the map.

+
+
+ Profile Picture +
+
+ +
+ Current avatar +
+ +
+

Choose a default avatar:

+
+ +
+
+ +
+ OR +
+ + +
+

Upload a custom image:

+
+ + +
+ Max 2MB. JPEG, PNG, GIF, or WebP format. +
+ + +
+ + + + + + +
+ +
+
+ +
+
+ + + diff --git a/src/main/resources/templates/settings/edit-place.html b/src/main/resources/templates/settings/edit-place.html index ef2c9a7d8..f070db3d5 100644 --- a/src/main/resources/templates/settings/edit-place.html +++ b/src/main/resources/templates/settings/edit-place.html @@ -271,7 +271,7 @@

Place Name

const tilesUrl = window.userSettings.tiles.service; const tilesAttribution = window.userSettings.tiles.attribution; - const tileLayer = window.userSettings.preferColoredMap ? L.tileLayer : L.tileLayer.grayscale; + const tileLayer = L.tileLayer.grayscale; tileLayer(tilesUrl, { maxZoom: 19, attribution: tilesAttribution @@ -454,7 +454,7 @@

Place Name

function showConfirmationDialog(warnings) { const warningsList = warnings.map(warning => `• ${warning}`).join('\n'); - const confirmMessage = /*t('js.places.update.confirmation.message')*/ 'The following changes will be made:\n\n{0}\n\nDo you want to continue?'; + const confirmMessage = t('places.update.confirmation.message'); const message = confirmMessage.replace('{0}', warningsList); if (confirm(message)) { showSaveLoading(); diff --git a/src/main/resources/templates/settings/export-data.html b/src/main/resources/templates/settings/export-data.html index 21a7cb9f0..6be23837e 100644 --- a/src/main/resources/templates/settings/export-data.html +++ b/src/main/resources/templates/settings/export-data.html @@ -35,6 +35,20 @@

Export Data

Date Range

+
+ + +
Date Range hx-target="#data-content" hx-swap="innerHTML" hx-trigger="change" - hx-include="#endDate, #pageSizeSelect" + hx-include="#deviceId, #endDate, #pageSizeSelect" hx-indicator="#loading-indicator" hx-vals='js:{"timezone": getUserTimezone()}' required> @@ -61,7 +75,7 @@

Date Range

hx-target="#data-content" hx-swap="innerHTML" hx-trigger="load, change" - hx-include="#startDate, #pageSizeSelect" + hx-include="#deviceId, #startDate, #pageSizeSelect" hx-indicator="#loading-indicator" hx-vals='js:{"timezone": getUserTimezone()}' required> @@ -117,7 +131,7 @@

Raw Location Data

th:hx-get="@{/settings/export-data/data-content}" hx-target="#data-content" hx-swap="innerHTML" - hx-include="#startDate, #endDate" + hx-include="#deviceId, #startDate, #endDate" hx-vals='js:{"timezone": getUserTimezone(), "page": 0}' hx-trigger="change"> @@ -134,7 +148,7 @@

Raw Location Data

th:hx-get="@{/settings/export-data/data-content}" hx-target="#data-content" hx-swap="innerHTML" - hx-include="#startDate, #endDate, #pageSizeSelect" + hx-include="#deviceId, #startDate, #endDate, #pageSizeSelect" th:hx-vals="|js:{'timezone': getUserTimezone(), 'page': ${currentPage - 1}}|" hx-trigger="click" th:text="#{export.raw.data.previous}"> @@ -151,7 +165,7 @@

Raw Location Data

th:hx-get="@{/settings/export-data/data-content}" hx-target="#data-content" hx-swap="innerHTML" - hx-include="#startDate, #endDate, #pageSizeSelect" + hx-include="#deviceId, #startDate, #endDate, #pageSizeSelect" th:hx-vals="|js:{'timezone': getUserTimezone(), 'page': ${currentPage + 1}}|" hx-trigger="click" th:text="#{export.raw.data.next}"> @@ -169,7 +183,6 @@

Raw Location Data

Latitude Longitude Accuracy (m) - Processed @@ -178,10 +191,6 @@

Raw Location Data

60.123456 24.123456 10.0 - - - - @@ -199,12 +208,13 @@

Raw Location Data

window.userSettings = /*[[${userSettings}]]*/ {} function handleGpxDownload(buttonElement) { + const deviceId = document.getElementById('deviceId').value; const startDate = document.getElementById('startDate').value; const endDate = document.getElementById('endDate').value; const relevantDataCheckbox = document.getElementById('relevantDataOnly'); const relevantDataOnly = relevantDataCheckbox ? relevantDataCheckbox.checked : false; - gpxDownloader.downloadGpx(startDate, endDate, buttonElement, relevantDataOnly); + gpxDownloader.downloadGpx(deviceId, startDate, endDate, buttonElement, relevantDataOnly); }
diff --git a/src/main/resources/templates/settings/fragments/api-tokens.html b/src/main/resources/templates/settings/fragments/api-tokens.html new file mode 100644 index 000000000..4b7074cff --- /dev/null +++ b/src/main/resources/templates/settings/fragments/api-tokens.html @@ -0,0 +1,30 @@ + + + +
+
+
+ +
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/settings/fragments/integrations.html b/src/main/resources/templates/settings/fragments/integrations.html index 06ed51378..7a8702caa 100644 --- a/src/main/resources/templates/settings/fragments/integrations.html +++ b/src/main/resources/templates/settings/fragments/integrations.html @@ -36,7 +36,7 @@

Integrations

Need Help?

For detailed setup instructions and tips, visit our comprehensive guide: - Setup Instructions:

+
+ 📍 Colota Setup +
+

Overland is a GPS logger for iOS that sends location data in GeoJSON format.

+

+ Homepage: + https://colota.app/ +

+
+

Setup Instructions:

+
    +
  1. Install Colota
  2. +
  3. Open Colota and go to the Settings > API Settings
  4. +
  5. Select the Reitti template
  6. +
  7. URL
  8. +
  9. URL
  10. +
  11. Set HTTP Header to: +
    +X-API-TOKEN: 123456
    +
    + +
  12. +
  13. Configure your tracking settings
  14. +
+
+
+ +
This will configure Colota to send data to this reitti instance.
+
+
+
📊 OwnTracks Recorder Integration
@@ -215,14 +246,24 @@

Setup Instructions:

th:value="${ownTracksRecorderIntegration?.authPassword}"> Leave empty if no authentication is needed
+
+ + + Select the device that incoming location data should be linked to +
+ -
-
-
-
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/settings/geocode-services.html b/src/main/resources/templates/settings/geocode-services.html index d7c4b0486..42258b142 100644 --- a/src/main/resources/templates/settings/geocode-services.html +++ b/src/main/resources/templates/settings/geocode-services.html @@ -136,7 +136,8 @@

Add New Geocoding Service

th:value="${type}" th:text="#{'geocoding.service.type.' + ${type} + '.name'}"> - + +
@@ -153,10 +154,7 @@

Add New Geocoding Service

- - -
diff --git a/src/main/resources/templates/settings/import-data.html b/src/main/resources/templates/settings/import-data.html index e4b26590f..f2adce0c2 100644 --- a/src/main/resources/templates/settings/import-data.html +++ b/src/main/resources/templates/settings/import-data.html @@ -42,9 +42,18 @@

GPX Files

th:hx-post="@{/settings/import/gpx}" hx-target="#file-upload" hx-encoding="multipart/form-data"> +
+
+ + +