diff --git a/app/components/app-menu/about.gjs b/app/components/app-menu/about.gjs index b6d793e..f3a66e5 100644 --- a/app/components/app-menu/about.gjs +++ b/app/components/app-menu/about.gjs @@ -2,6 +2,7 @@ import { on } from '@ember/modifier'; import Icon from '#components/icon'; diff --git a/app/components/app-menu/settings.gjs b/app/components/app-menu/settings.gjs index f85b7c7..e4cfdbc 100644 --- a/app/components/app-menu/settings.gjs +++ b/app/components/app-menu/settings.gjs @@ -18,6 +18,11 @@ export default class AppMenuSettings extends Component { this.settings.updateMapKinetic(event.target.value === 'true'); } + @action + updatePhotonApi(event) { + this.settings.updatePhotonApi(event.target.value); + } + diff --git a/app/components/map.gjs b/app/components/map.gjs index e434fc6..c766f64 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -499,10 +499,9 @@ export default class MapComponent extends Component { } if (options.preventZoom) { - // If we are preventing zoom (e.g. user clicked a bookmark), we still need to center - // but without changing the zoom level. - // We use animateToSmartCenter without a second argument (zoom=null). - this.animateToSmartCenter(coords); + // If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check. + // This avoids unnecessary panning if the place is already visible. + this.handlePinVisibility(coords, { maintainZoom: true }); } else if (selected.bbox) { this.zoomToBbox(selected.bbox); } else { @@ -547,7 +546,10 @@ export default class MapComponent extends Component { } // Desktop: Sidebar covers left side (approx 400px) else if (this.args.isSidebarOpen) { - const sidebarWidth = 400; + const sidebarWidthVar = getComputedStyle(document.documentElement) + .getPropertyValue('--sidebar-width') + .trim(); + const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360; const visibleWidth = size[0] - sidebarWidth; // Left padding: Sidebar + 15% of visible width @@ -566,14 +568,15 @@ export default class MapComponent extends Component { }); } - handlePinVisibility(coords) { + handlePinVisibility(coords, options = {}) { if (!this.mapInstance) return; const view = this.mapInstance.getView(); const currentZoom = view.getZoom(); // If too far out (e.g. world view), zoom in to neighborhood level (16) - if (currentZoom < 16) { + // UNLESS we want to maintain the current zoom + if (!options.maintainZoom && currentZoom < 16) { this.animateToSmartCenter(coords, 16); return; } @@ -590,8 +593,12 @@ export default class MapComponent extends Component { pixel[1] > size[1]; if (isOffScreen) { - this.animateToSmartCenter(coords); + // If off-screen, center it smartly (considering sidebar/bottom sheet) + // Pass maintainZoom to prevent zoom reset if desired + const zoom = options.maintainZoom ? null : 16; + this.animateToSmartCenter(coords, zoom); } else { + // If on-screen, only pan if obscured by UI this.panIfObscured(coords); } } @@ -627,6 +634,28 @@ export default class MapComponent extends Component { // To move the camera South (Lower Y), we subtract. targetCenter = [coords[0], coords[1] - offsetMapUnits]; } + // Desktop: Check if sidebar is open + else if (this.args.isSidebarOpen) { + const sidebarWidthVar = getComputedStyle(document.documentElement) + .getPropertyValue('--sidebar-width') + .trim(); + const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360; + + // We want the pin to be in the center of the remaining space. + // Remaining space starts at x = sidebarWidth. + // Center of remaining space = sidebarWidth + (totalWidth - sidebarWidth) / 2 + // = sidebarWidth/2 + totalWidth/2 + // Map Center is totalWidth/2 + // Offset = sidebarWidth/2 (to the right) + + const offsetPixels = sidebarWidth / 2; + const offsetMapUnits = offsetPixels * resolution; + + // We want pin at center + offset. + // So map center must be pin - offset. + // X increases to the right. + targetCenter = [coords[0] - offsetMapUnits, coords[1]]; + } const animationOptions = { center: targetCenter, @@ -645,33 +674,73 @@ export default class MapComponent extends Component { if (!this.mapInstance) return; const size = this.mapInstance.getSize(); - // Check if mobile (width <= 768px matches CSS) - if (size[0] > 768) return; - const pixel = this.mapInstance.getPixelFromCoordinate(coords); if (!pixel) return; - const height = size[1]; + const view = this.mapInstance.getView(); + const center = view.getCenter(); + const resolution = view.getResolution(); - // Sidebar covers the bottom 50% - const splitPoint = height / 2; + // Default targets (current position) + let targetPixelX = pixel[0]; + let targetPixelY = pixel[1]; + let needsPan = false; - // If the pin is in the bottom half (y > splitPoint), it is obscured - if (pixel[1] > splitPoint) { - // Target position: Center of top half = height * 0.25 - const targetY = height * 0.25; - const deltaY = pixel[1] - targetY; + // 1. Mobile Bottom Sheet Logic (Screen <= 768px) + if (size[0] <= 768) { + const height = size[1]; + const splitPoint = height / 2; - const view = this.mapInstance.getView(); - const center = view.getCenter(); - const resolution = view.getResolution(); + // If in bottom half + if (pixel[1] > splitPoint) { + targetPixelY = height * 0.25; // Target: Center of top half + needsPan = true; + } + } + // 2. Desktop Sidebar Logic (Screen > 768px + Sidebar Open) + else if (this.args.isSidebarOpen) { + const sidebarWidthVar = getComputedStyle(document.documentElement) + .getPropertyValue('--sidebar-width') + .trim(); + const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360; - // Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y) - const deltaMapUnits = deltaY * resolution; - const newCenter = [center[0], center[1] - deltaMapUnits]; + // If under sidebar + if (pixel[0] < sidebarWidth) { + const visibleWidth = size[0] - sidebarWidth; + targetPixelX = sidebarWidth + visibleWidth / 2; // Target: Center of visible area + needsPan = true; + } + } + + // 3. Header Logic (Any screen size) + // Check if the (potentially new) target Y is under the header + const headerHeight = 60; + const minTopDistance = headerHeight + 20; // 80px + + if (targetPixelY < minTopDistance) { + targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header + needsPan = true; + } + + if (needsPan) { + const deltaPixelX = pixel[0] - targetPixelX; + const deltaPixelY = pixel[1] - targetPixelY; + + // X: Camera moves same direction as we want the world to move? No. + // If we want pin to move RIGHT (pixel increases), Camera must move LEFT (X decreases). + // deltaPixelX = current - target. If current < target (want move right), delta is negative. + // center + negative = decrease. Correct. + const newCenterX = center[0] + deltaPixelX * resolution; + + // Y: Camera moves opposite direction to world relative to pixel coords. + // Pixel Y increases DOWN. Map Y increases UP. + // If we want pin to move DOWN (pixel increases), Camera must move UP (Y increases). + // deltaPixelY = current - target. If current < target (want move down), delta is negative. + // center - negative = increase. Correct. + const newCenterY = center[1] - deltaPixelY * resolution; view.animate({ - center: newCenter, + center: [newCenterX, newCenterY], duration: 500, easing: (t) => t * (2 - t), // Ease-out }); diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 732b7b2..82463c7 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -286,7 +286,7 @@ export default class PlaceDetails extends Component { > {{if this.isSaved "Saved" "Save"}} @@ -307,7 +307,7 @@ export default class PlaceDetails extends Component { title="Edit" {{on "click" this.startEditing}} > - + Edit {{/if}} diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index d356799..fb81bd2 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -226,7 +226,7 @@ export default class PlacesSidebar extends Component { class="btn btn-outline create-place" {{on "click" this.createNewPlace}} > - + Create new place {{/if}} diff --git a/app/services/photon.js b/app/services/photon.js index 4367d06..b17ae66 100644 --- a/app/services/photon.js +++ b/app/services/photon.js @@ -1,9 +1,13 @@ -import Service from '@ember/service'; +import Service, { service } from '@ember/service'; import { getPlaceType } from '../utils/osm'; import { humanizeOsmTag } from '../utils/format-text'; export default class PhotonService extends Service { - baseUrl = 'https://photon.komoot.io/api/'; + @service settings; + + get baseUrl() { + return this.settings.photonApi; + } async search(query, lat, lon, limit = 10) { if (!query || query.length < 2) return []; diff --git a/app/services/settings.js b/app/services/settings.js index 3c91153..a571b7f 100644 --- a/app/services/settings.js +++ b/app/services/settings.js @@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking'; export default class SettingsService extends Service { @tracked overpassApi = 'https://overpass-api.de/api/interpreter'; @tracked mapKinetic = true; + @tracked photonApi = 'https://photon.komoot.io/api/'; overpassApis = [ { @@ -24,6 +25,13 @@ export default class SettingsService extends Service { // }, ]; + photonApis = [ + { + name: 'photon.komoot.io', + url: 'https://photon.komoot.io/api/', + }, + ]; + constructor() { super(...arguments); this.loadSettings(); @@ -59,4 +67,8 @@ export default class SettingsService extends Service { this.mapKinetic = enabled; localStorage.setItem('marco:map-kinetic', String(enabled)); } + + updatePhotonApi(url) { + this.photonApi = url; + } } diff --git a/app/styles/app.css b/app/styles/app.css index b68e4a3..1cc0606 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -3,6 +3,9 @@ :root { --default-list-color: #fc3; --hover-bg: #f8f9fa; + --sidebar-width: 360px; + --link-color: #2a7fff; + --link-color-visited: #6a4fbf; } html, @@ -185,7 +188,7 @@ body { } .text-primary { - color: #007bff; + color: var(--link-color); } .text-danger { @@ -202,7 +205,7 @@ body { top: 0; left: 0; bottom: 0; - width: 300px; + width: var(--sidebar-width); background: white; z-index: 3100; /* Higher than Header (3000) */ box-shadow: 2px 0 5px rgb(0 0 0 / 10%); @@ -285,6 +288,82 @@ body { height: 20px; } +.sidebar-content details { + margin: 0 -1rem; /* Top margin, negative side margins to span full width */ +} + +.sidebar-content details summary { + list-style: none; /* Hide default triangle */ + display: flex; + align-items: center; + gap: 0.8rem; + padding: 1rem; + padding-left: 1.4rem; + cursor: pointer; + font-size: 0.95rem; + color: #333; + transition: background-color 0.2s; +} + +.sidebar-content details summary::-webkit-details-marker { + display: none; /* Hide default triangle in WebKit */ +} + +.sidebar-content details summary:hover { + background-color: var(--hover-bg); +} + +.sidebar-content details summary .icon { + width: 20px; + height: 20px; +} + +.sidebar-content details summary::after { + content: ''; + width: 20px; + height: 20px; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E"); + background-size: 20px 20px; + background-repeat: no-repeat; + background-position: center; + margin-left: auto; + transition: transform 0.2s ease; +} + +.sidebar-content details[open] summary::after { + transform: rotate(90deg); +} + +.sidebar-content details .details-content { + padding: 0 1.4rem 1rem; + animation: details-slide-down 0.2s ease-out; +} + +.sidebar-content details .link-list { + padding: 0; + margin: 0; +} + +@keyframes details-slide-down { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.sidebar-content details .link-list li { + margin-bottom: 0.5rem; +} + +.sidebar-content details .link-list li:last-child { + margin-bottom: 0; +} + .edit-form { margin: -1rem; margin-bottom: 1rem; @@ -312,12 +391,25 @@ body { font-family: inherit; font-size: 0.95rem; box-sizing: border-box; /* Ensure padding doesn't overflow width */ + color: #333; + background-color: #fff; } .form-control:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgb(0 123 255 / 10%); + border-color: var(--link-color); + box-shadow: 0 0 0 2px rgb(42 127 255 / 10%); +} + +select.form-control { + appearance: none; + background-color: #fff; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 16px; + padding-right: 2.5rem; + cursor: pointer; } .edit-actions { @@ -335,12 +427,21 @@ body { margin-top: 1rem; } -.settings-section p a { - color: #007bff; +.about-section { + margin-bottom: 2rem; + font-size: 0.95rem; +} + +.about-section a { + color: var(--link-color); text-decoration: none; } -.settings-section p a:hover { +.about-section a:visited { + color: var(--link-color-visited); +} + +.about-section a:hover { text-decoration: underline; } @@ -349,7 +450,7 @@ body { } .btn-primary { - background: #007bff; + background: var(--link-color); color: white; border: none; padding: 0.75rem; @@ -378,7 +479,7 @@ body { } .meta-info a { - color: #007bff; + color: var(--link-color); text-decoration: none; } @@ -386,6 +487,37 @@ body { text-decoration: underline; } +.sidebar-content table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.sidebar-content table th, +.sidebar-content table td { + padding: 0.5rem 0; + text-align: left; +} + +.sidebar-content table th { + font-size: 0.75rem; + font-weight: bold; + text-transform: uppercase; + color: #898989; +} + +.sidebar-content table td { + border-bottom: 1px solid #f9f9f9; +} + +.sidebar-content table tr:last-child td { + border-bottom: none; +} + +abbr[title] { + text-decoration: underline dotted; +} + .link-list { list-style: none; padding: 0; @@ -397,7 +529,7 @@ body { } .link-list a { - color: #007bff; + color: var(--link-color); text-decoration: none; font-size: 0.95rem; } @@ -522,7 +654,7 @@ body { } .btn-blue { - background: #007bff; + background: var(--link-color); color: white; border: none; } @@ -767,15 +899,14 @@ span.icon { display: block; } -/* Sidebar is open (Desktop: Left 300px) */ +/* Sidebar is open (Desktop: Left var(--sidebar-width)) */ -/* We want to center in the remaining space (width - 300px) */ +/* We want to center in the remaining space (width - var(--sidebar-width)) */ -/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */ +/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */ -/* So shift left by 150px from center */ .map-container.sidebar-open .map-crosshair { - left: calc(50% + 150px); + left: calc(50% + var(--sidebar-width) / 2); } @media (width <= 768px) { @@ -1063,7 +1194,7 @@ button.create-place { } .place-lists-manager input[type='checkbox'] { - accent-color: #007bff; + accent-color: var(--link-color); width: 16px; height: 16px; cursor: pointer; diff --git a/app/utils/icons.js b/app/utils/icons.js index 83e0cf8..ec0d034 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -5,7 +5,9 @@ import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw'; import clock from 'feather-icons/dist/icons/clock.svg?raw'; import edit from 'feather-icons/dist/icons/edit.svg?raw'; import facebook from 'feather-icons/dist/icons/facebook.svg?raw'; +import gift from 'feather-icons/dist/icons/gift.svg?raw'; import globe from 'feather-icons/dist/icons/globe.svg?raw'; +import heart from 'feather-icons/dist/icons/heart.svg?raw'; import home from 'feather-icons/dist/icons/home.svg?raw'; import info from 'feather-icons/dist/icons/info.svg?raw'; import instagram from 'feather-icons/dist/icons/instagram.svg?raw'; @@ -35,7 +37,9 @@ const ICONS = { clock, edit, facebook, + gift, globe, + heart, home, info, instagram,