opening_hours
Version:
Library to parse and process opening_hours tag from OpenStreetMap data
380 lines (336 loc) • 16.9 kB
JavaScript
// Import all required modules
import i18next from '../../node_modules/i18next/dist/esm/i18next.bundled.js';
import { resources, detectLanguage, getUserSelectTranslateHTMLCode, changeLanguage } from './i18n-resources.js';
import { Evaluate, EX, josm, toggle, dateAtWeek, newValue, currentDateTime } from './helpers.js';
// Configuration constants
window.default_lat = 48.7769;
window.default_lon = 9.1844;
window.repo_url = 'https://github.com/opening-hours/opening_hours.js';
// Helper function to generate time navigation buttons
function generateTimeButtons() {
const buttons = [
[ 3600 * 24 * 365, 1, 'words.time.year' , 'year' ],
[ null , 1, 'words.time.month' , 'month' ], // Special handling for month
[ 3600 * 24 , 1, 'words.time.day' , 'day' ],
[ 3600 , 1, 'words.time.hour' , 'hour' ],
[ 60 , 1, 'words.time.minute' , 'minute' ],
[ 3600 * 24 * 7, 1, 'words.time.week' , 'week' ],
[ 0 , 0, 'words.time.now' , null ],
];
let html = '';
for (let i = 0; i < buttons.length; i++) {
if (buttons[i][1] !== 0) {
const offset = buttons[i][0];
const field = buttons[i][3];
const isEditable = field === 'year' || field === 'day' || field === 'hour' || field === 'minute';
const labelText = i18next.t(buttons[i][2]);
const dataAttr = field === 'month' ? 'data-month-offset' : 'data-offset';
const minusVal = field === 'month' ? -1 : -offset;
const plusVal = field === 'month' ? 1 : offset;
html += `<div class="time-btn-wrapper">
<label class="time-btn-label-top">${labelText}</label>
<div class="time-btn-group">
<button type="button" class="time-btn time-btn-minus" ${dataAttr}="${minusVal}" title="-1 ${labelText}">−</button>
<span class="time-btn-label${isEditable ? ' editable' : ''}" id="time-btn-value-${field}" ${isEditable ? 'contenteditable="true" spellcheck="false"' : ''} data-field="${field}"></span>
<button type="button" class="time-btn time-btn-plus" ${dataAttr}="${plusVal}" title="+1 ${labelText}">+</button>
</div>`;
// Add weekday display below day button
if (buttons[i][3] === 'day') {
html += '<span class="time-btn-label-bottom" id="time-display-wday"></span>';
}
html += '</div>';
} else {
html += `<button type="button" class="time-btn time-btn-now">${i18next.t(buttons[i][2])}</button>`;
}
}
return html;
}
// Helper function to update time button labels with current values
export function updateTimeButtonLabels(date) {
const yearLabel = document.getElementById('time-btn-value-year');
const monthLabel = document.getElementById('time-btn-value-month');
const dayLabel = document.getElementById('time-btn-value-day');
const hourLabel = document.getElementById('time-btn-value-hour');
const minuteLabel = document.getElementById('time-btn-value-minute');
const weekLabel = document.getElementById('time-btn-value-week');
const wdayDisplay = document.getElementById('time-display-wday');
function u2(v) { return v >= 0 && v < 10 ? `0${v}` : v; }
if (yearLabel) yearLabel.textContent = currentDateTime.year;
if (monthLabel) {
monthLabel.textContent = new Date(2018, currentDateTime.month, 1)
.toLocaleString(i18next.language, {month: 'short'});
}
if (dayLabel) dayLabel.textContent = u2(currentDateTime.day);
if (hourLabel) hourLabel.textContent = u2(currentDateTime.hour);
if (minuteLabel) minuteLabel.textContent = u2(currentDateTime.minute);
if (weekLabel && date) {
weekLabel.textContent = `W${u2(dateAtWeek(date, 0) + 1)}`;
}
if (wdayDisplay && date) {
wdayDisplay.textContent = date.toLocaleString(i18next.language, {weekday: 'short'});
}
}
// Helper function to generate mode selector options
function generateModeOptions() {
let options = '';
for (let i = 0; i <= 2; i++) {
options += `<option value="${i}">${i18next.t(`texts.mode ${i}`)}</option>`;
}
return options;
}
// Populate all dynamic content with localized text
function initializeUI() {
// Page title
document.getElementById('page-title').textContent = i18next.t('texts.title');
// Language selector
document.getElementById('language-selector').innerHTML = getUserSelectTranslateHTMLCode();
// Date and time inputs with navigation buttons only
document.getElementById('date-time-inputs').innerHTML = `
<h2>${i18next.t('words.date')} ${i18next.t('words.and')} ${i18next.t('words.time.time')}</h2>
` + generateTimeButtons();
// Position inputs
document.getElementById('position-inputs').innerHTML = `
<h2>${i18next.t('words.position')}</h2>
${i18next.t('words.lat')} <input type="number" class="input__coordinate" id="lat" value="${window.default_lat}" />
${i18next.t('words.lon')} <input type="number" class="input__coordinate" id="lon" value="${window.default_lon}" />
${i18next.t('words.country')} <input size="3" id="cc" readonly="readonly" />
${i18next.t('words.state')} <input size="20" id="state" readonly="readonly" /><br />
`;
// Mode selector
document.getElementById('mode-selector').innerHTML = `
<h2>${i18next.t('words.mode')}</h2>
<select id="mode" name="mode" style="max-width:100%;">
${generateModeOptions()}
</select>
`;
// Value section heading
document.getElementById('value-section-heading').textContent = i18next.t('texts.value for') + ' „opening_hours“';
// Value label (hide - redundant with section heading)
document.getElementById('value-label').style.display = 'none';
// Actions section
document.getElementById('actions-heading').textContent = i18next.t('words.actions');
document.getElementById('compare-label').textContent = i18next.t('texts.value to compare');
document.getElementById('action-permalink-label').textContent = i18next.t('texts.share config');
document.getElementById('permalink-with-timestamp-label').textContent = i18next.t('texts.with timestamp');
document.getElementById('permalink-without-timestamp-label').textContent = i18next.t('texts.without timestamp');
// Set title attributes for all buttons with data-i18n-title
document.querySelectorAll('[data-i18n-title]').forEach(btn => {
const key = btn.getAttribute('data-i18n-title');
btn.title = i18next.t('texts.' + key);
});
// Copy button handlers for permalink copy buttons
document.querySelectorAll('.copy-permalink-btn').forEach(btn => {
btn.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const targetLink = document.getElementById(targetId);
if (targetLink) {
navigator.clipboard.writeText(targetLink.href).catch(() => {});
}
});
});
// Copy and clear buttons for expression input
document.getElementById('copy-expression-btn').addEventListener('click', function() {
const expressionInput = document.getElementById('expression');
navigator.clipboard.writeText(expressionInput.value).catch(() => {});
});
document.getElementById('clear-expression-btn').addEventListener('click', function() {
document.getElementById('expression').value = '';
Evaluate();
});
document.getElementById('clear-diff-btn').addEventListener('click', function() {
document.getElementById('diff_value').value = '';
Evaluate();
});
// Results section heading
document.getElementById('results-heading').textContent = i18next.t('words.results');
// Examples header
document.getElementById('examples').textContent = i18next.t('words.examples');
// Year ranges documentation link
const yearRangesDocu = document.getElementById('year-ranges-docu');
yearRangesDocu.href = `${window.repo_url}/tree/main#year-ranges`;
yearRangesDocu.textContent = i18next.t('words.docu');
// Example hints
document.getElementById('hint-error-correction-1').textContent = `(${i18next.t('texts.check out error correction, prettify')})`;
document.getElementById('hint-error-correction-2').textContent = `(${i18next.t('texts.check out error correction, prettify')})`;
document.getElementById('hint-ph-mo-fr').textContent = `(${i18next.t('texts.if PH is between Mo and Fr')})`;
document.getElementById('hint-sh-ph').textContent = `(${i18next.t('texts.SH,PH or PH,SH')})`;
// Footer content
document.getElementById('footer').innerHTML = i18next.t('texts.more information',
{ href: 'https://wiki.openstreetmap.org/wiki/Key:opening_hours' }) + '<br />' +
i18next.t('texts.this website', { url: window.repo_url, hoster: 'GitHub' });
document.body.parentElement.lang = i18next.language;
}
// Set up event listeners using event delegation (only for repetitive handlers)
function setupEventListeners() {
const main = document.getElementById('user');
// Input field listeners
document.getElementById('expression').addEventListener('keyup', () => Evaluate());
document.getElementById('expression').addEventListener('blur', () => Evaluate());
document.getElementById('diff_value').addEventListener('keyup', () => Evaluate());
document.getElementById('diff_value').addEventListener('blur', () => Evaluate());
document.getElementById('lat').addEventListener('blur', () => Evaluate());
document.getElementById('lon').addEventListener('blur', () => Evaluate());
document.getElementById('mode').addEventListener('change', () => Evaluate());
// Language selector
document.getElementById('language-select').addEventListener('change', function() {
changeLanguage(this.value);
});
// Use mousedown instead of click for prettified value elements
// This prevents interference with browser's text selection behavior
main.addEventListener('mousedown', (e) => {
// Prettified value display (code element)
if (e.target.closest('.prettified-value-display')) {
e.preventDefault();
e.stopImmediatePropagation();
const code = e.target.closest('.prettified-value-display');
newValue(code.dataset.value);
}
// Copy button for prettified value
else if (e.target.closest('.copy-prettified-btn')) {
e.preventDefault();
e.stopImmediatePropagation();
const btn = e.target.closest('.copy-prettified-btn');
navigator.clipboard.writeText(btn.dataset.value).catch(() => {});
}
// Copy prettified value link
else if (e.target.closest('.copy-prettified-value')) {
e.preventDefault();
e.stopImmediatePropagation();
const link = e.target.closest('.copy-prettified-value');
newValue(link.dataset.value);
}
// Time jump links/buttons (from opening_hours_table.js)
else if (e.target.closest('.time-jump, .time-jump-btn')) {
e.preventDefault();
e.stopImmediatePropagation();
const link = e.target.closest('.time-jump, .time-jump-btn');
Evaluate(parseInt(link.dataset.offset, 10), false);
}
});
main.addEventListener('click', (e) => {
// Examples toggle
if (e.target.closest('#examples-toggle')) {
e.preventDefault();
const toggleBtn = e.target.closest('#examples-toggle');
toggleBtn.classList.toggle('collapsed');
toggle('user_examples');
}
// Example links (60+ handlers → 1 listener)
else if (e.target.closest('.example-link')) {
e.preventDefault();
EX(e.target.closest('.example-link'));
}
// Time buttons
else if (e.target.closest('.time-btn')) {
const btn = e.target.closest('.time-btn');
if (btn.classList.contains('time-btn-now')) {
Evaluate(0, true);
} else if (btn.hasAttribute('data-month-offset')) {
// Month: use Date to handle day overflow automatically
const dt = currentDateTime;
const targetMonth = dt.month + parseInt(btn.dataset.monthOffset, 10);
const lastDay = new Date(dt.year, targetMonth + 1, 0).getDate();
const newDate = new Date(dt.year, targetMonth, Math.min(dt.day, lastDay), dt.hour, dt.minute);
currentDateTime.year = newDate.getFullYear();
currentDateTime.month = newDate.getMonth();
currentDateTime.day = newDate.getDate();
currentDateTime.hour = newDate.getHours();
currentDateTime.minute = newDate.getMinutes();
Evaluate();
} else {
Evaluate(parseInt(btn.dataset.offset, 10));
}
}
// JOSM link
else if (e.target.closest('.josm-link')) {
e.preventDefault();
const link = e.target.closest('.josm-link');
josm(link.dataset.url);
}
});
// Editable time values
main.addEventListener('keydown', (e) => {
if (e.target.classList.contains('time-btn-label') && e.target.hasAttribute('contenteditable')) {
// Allow Enter to commit the value
if (e.key === 'Enter') {
e.preventDefault();
e.target.blur();
}
// Allow Escape to cancel
else if (e.key === 'Escape') {
e.preventDefault();
e.target.textContent = e.target.dataset.originalValue || e.target.textContent;
e.target.blur();
}
}
});
main.addEventListener('focus', (e) => {
if (e.target.classList.contains('time-btn-label') && e.target.hasAttribute('contenteditable')) {
// Store original value for Escape key
e.target.dataset.originalValue = e.target.textContent;
// Select all text for easy replacement
const range = document.createRange();
range.selectNodeContents(e.target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
}, true);
main.addEventListener('blur', (e) => {
if (e.target.classList.contains('time-btn-label') && e.target.hasAttribute('contenteditable')) {
const field = e.target.dataset.field;
const value = e.target.textContent.trim();
// Validate and update based on field type
if (field === 'year') {
const year = parseInt(value, 10);
if (!isNaN(year) && year >= 1970 && year <= 2100) {
currentDateTime.year = year;
Evaluate();
} else {
updateTimeButtonLabels();
}
} else if (field === 'day') {
const day = parseInt(value, 10);
if (!isNaN(day) && day >= 1 && day <= 31) {
currentDateTime.day = day;
Evaluate();
} else {
updateTimeButtonLabels();
}
} else if (field === 'hour') {
const hour = parseInt(value, 10);
if (!isNaN(hour) && hour >= 0 && hour <= 23) {
currentDateTime.hour = hour;
Evaluate();
} else {
updateTimeButtonLabels();
}
} else if (field === 'minute') {
const minute = parseInt(value, 10);
if (!isNaN(minute) && minute >= 0 && minute <= 59) {
currentDateTime.minute = minute;
Evaluate();
} else {
updateTimeButtonLabels();
}
}
delete e.target.dataset.originalValue;
}
}, true);
}
/* Initialize application (ES6 modules execute after DOM parsing) */
await i18next.init({
lng: detectLanguage(),
fallbackLng: 'en',
resources: resources,
debug: false
});
// Update specification_url after i18next is ready
window.specification_url = `https://wiki.openstreetmap.org/wiki/${i18next.language === 'de' ? 'DE:' : ''}Key:opening_hours/specification`;
// Set page title
if (document.title !== i18next.t('texts.title')) {
document.title = i18next.t('texts.title');
}
initializeUI();
setupEventListeners();
Evaluate();