Create new places
And find them in search
This commit is contained in:
@@ -15,7 +15,6 @@ import Point from 'ol/geom/Point.js';
|
|||||||
import Geolocation from 'ol/Geolocation.js';
|
import Geolocation from 'ol/Geolocation.js';
|
||||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||||
import { apply } from 'ol-mapbox-style';
|
import { apply } from 'ol-mapbox-style';
|
||||||
import { getDistance } from '../utils/geo';
|
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -29,6 +28,8 @@ export default class MapComponent extends Component {
|
|||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
selectedPinOverlay;
|
selectedPinOverlay;
|
||||||
selectedPinElement;
|
selectedPinElement;
|
||||||
|
crosshairElement; // New crosshair
|
||||||
|
crosshairOverlay; // New crosshair overlay
|
||||||
|
|
||||||
setupMap = modifier((element) => {
|
setupMap = modifier((element) => {
|
||||||
if (this.mapInstance) return;
|
if (this.mapInstance) return;
|
||||||
@@ -140,6 +141,29 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
this.mapInstance.addOverlay(this.selectedPinOverlay);
|
this.mapInstance.addOverlay(this.selectedPinOverlay);
|
||||||
|
|
||||||
|
// Crosshair Overlay (for Creating New Place)
|
||||||
|
this.crosshairElement = document.createElement('div');
|
||||||
|
this.crosshairElement.className = 'map-crosshair';
|
||||||
|
// Use an SVG or simple CSS cross
|
||||||
|
this.crosshairElement.innerHTML = `
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
// We attach it to the map control container OR keep it as an overlay centered on map center?
|
||||||
|
// Actually, a fixed center overlay is trickier in OpenLayers because Overlays move with the map.
|
||||||
|
// If we want it FIXED in the center of the VIEWPORT, it should be a Control or just an absolute HTML element on top of the map div.
|
||||||
|
// Adding it as a Control is cleaner.
|
||||||
|
|
||||||
|
// HOWEVER, the request says "cross hair drawn on the map... which should be removed when saving".
|
||||||
|
// A fixed element in the center of the screen is best for "choose location by dragging map".
|
||||||
|
// So let's append it to the map container directly via Glimmer template or JS.
|
||||||
|
|
||||||
|
// We'll append it to the map target element (this.element is the target).
|
||||||
|
element.appendChild(this.crosshairElement);
|
||||||
|
|
||||||
|
|
||||||
// Geolocation Pulse Overlay
|
// Geolocation Pulse Overlay
|
||||||
this.locationOverlayElement = document.createElement('div');
|
this.locationOverlayElement = document.createElement('div');
|
||||||
this.locationOverlayElement.className = 'search-pulse blue';
|
this.locationOverlayElement.className = 'search-pulse blue';
|
||||||
@@ -522,9 +546,128 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync the creation mode (Crosshair)
|
||||||
|
syncCreationMode = modifier(() => {
|
||||||
|
if (!this.crosshairElement || !this.mapInstance) return;
|
||||||
|
|
||||||
|
if (this.mapUi.isCreating) {
|
||||||
|
this.crosshairElement.classList.add('visible');
|
||||||
|
|
||||||
|
// If we have initial coordinates from the route (e.g. reload or link),
|
||||||
|
// we need to pan the map so those coordinates are UNDER the crosshair.
|
||||||
|
const coords = this.mapUi.creationCoordinates;
|
||||||
|
if (coords && coords.lat && coords.lon) {
|
||||||
|
// We only animate if the map center isn't already "roughly" correct
|
||||||
|
// But actually, updateCreationCoordinates is called by handleMapMove too.
|
||||||
|
// We need to distinguish "initial set" vs "drag update".
|
||||||
|
// The Service doesn't distinguish, but if we are just entering mode,
|
||||||
|
// we can check if the current map center aligns.
|
||||||
|
|
||||||
|
// Better approach:
|
||||||
|
// We calculate where the map center *should* be to put the target coords
|
||||||
|
// under the crosshair.
|
||||||
|
const targetCoords = fromLonLat([coords.lon, coords.lat]);
|
||||||
|
this.animateToCrosshair(targetCoords);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.crosshairElement.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
animateToCrosshair(targetCoords) {
|
||||||
|
if (!this.mapInstance || !this.crosshairElement) return;
|
||||||
|
|
||||||
|
// 1. Get current visual position of the crosshair
|
||||||
|
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||||
|
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const crosshairPixelX =
|
||||||
|
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||||
|
const crosshairPixelY =
|
||||||
|
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||||
|
|
||||||
|
// 2. Get the center pixel of the map viewport
|
||||||
|
const size = this.mapInstance.getSize();
|
||||||
|
const mapCenterX = size[0] / 2;
|
||||||
|
const mapCenterY = size[1] / 2;
|
||||||
|
|
||||||
|
// 3. Calculate the offset (how far the crosshair is from the geometric center)
|
||||||
|
const offsetX = crosshairPixelX - mapCenterX;
|
||||||
|
const offsetY = crosshairPixelY - mapCenterY;
|
||||||
|
|
||||||
|
// 4. Calculate the new map center
|
||||||
|
// We want 'targetCoords' to be at [crosshairPixelX, crosshairPixelY].
|
||||||
|
// If we center the map on 'targetCoords', it will be at [mapCenterX, mapCenterY].
|
||||||
|
// So we need to shift the map center by the OPPOSITE of the offset.
|
||||||
|
// Wait.
|
||||||
|
// If crosshair is to the right (+X), we need to move the camera LEFT (-X) to bring the point there?
|
||||||
|
// Let's think in map units.
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const resolution = view.getResolution();
|
||||||
|
|
||||||
|
const offsetMapUnitsX = offsetX * resolution;
|
||||||
|
const offsetMapUnitsY = -offsetY * resolution; // Y is inverted in pixel vs map coords usually?
|
||||||
|
// In Web Mercator: Y increases North (Up).
|
||||||
|
// In Pixels: Y increases South (Down).
|
||||||
|
// So +PixelY (Down) = -MapY (South). Correct.
|
||||||
|
|
||||||
|
// If crosshair is at +100px (Right), we want the target to be there.
|
||||||
|
// If we center on target, it is at 0px.
|
||||||
|
// To make it appear at +100px, we must shift the camera center by -100px (Left).
|
||||||
|
// So CenterX_new = TargetX - offsetMapUnitsX.
|
||||||
|
|
||||||
|
const targetX = targetCoords[0];
|
||||||
|
const targetY = targetCoords[1];
|
||||||
|
|
||||||
|
const newCenterX = targetX - offsetMapUnitsX;
|
||||||
|
const newCenterY = targetY - offsetMapUnitsY;
|
||||||
|
|
||||||
|
// Only animate if the difference is significant (avoid micro-jitters/loops)
|
||||||
|
const currentCenter = view.getCenter();
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
Math.pow(currentCenter[0] - newCenterX, 2) +
|
||||||
|
Math.pow(currentCenter[1] - newCenterY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1 meter is approx 1 unit in Mercator near equator, varies by latitude.
|
||||||
|
// Resolution at zoom 18 is approx 0.6m/pixel.
|
||||||
|
// Let's use a small pixel threshold.
|
||||||
|
if (dist > resolution * 5) {
|
||||||
|
view.animate({
|
||||||
|
center: [newCenterX, newCenterY],
|
||||||
|
duration: 800,
|
||||||
|
easing: (t) => t * (2 - t), // Ease-out
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMapMove = async () => {
|
handleMapMove = async () => {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
|
// If in creation mode, update the coordinates in the service AND the URL
|
||||||
|
if (this.mapUi.isCreating) {
|
||||||
|
// Calculate coordinates under the crosshair element
|
||||||
|
// We need the pixel position of the crosshair relative to the map viewport
|
||||||
|
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
||||||
|
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||||
|
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||||
|
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||||
|
|
||||||
|
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
|
||||||
|
const center = toLonLat(coordinate);
|
||||||
|
|
||||||
|
const lat = parseFloat(center[1].toFixed(6));
|
||||||
|
const lon = parseFloat(center[0].toFixed(6));
|
||||||
|
|
||||||
|
this.mapUi.updateCreationCoordinates(lat, lon);
|
||||||
|
|
||||||
|
// Update URL without triggering a full refresh
|
||||||
|
// We use replaceWith to avoid cluttering history
|
||||||
|
this.router.replaceWith('place.new', { queryParams: { lat, lon } });
|
||||||
|
}
|
||||||
|
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
const extent = this.mapInstance.getView().calculateExtent(size);
|
const extent = this.mapInstance.getView().calculateExtent(size);
|
||||||
const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
|
const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
|
||||||
@@ -640,11 +783,12 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="map-container"
|
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
|
||||||
{{this.setupMap}}
|
{{this.setupMap}}
|
||||||
{{this.updateBookmarks}}
|
{{this.updateBookmarks}}
|
||||||
{{this.updateSelectedPin}}
|
{{this.updateSelectedPin}}
|
||||||
{{this.syncPulse}}
|
{{this.syncPulse}}
|
||||||
|
{{this.syncCreationMode}}
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,13 @@ import { fn } from '@ember/helper';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked editTitle = '';
|
|
||||||
@tracked editDescription = '';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
this.resetEditFields();
|
|
||||||
}
|
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
@@ -34,16 +28,9 @@ export default class PlaceDetails extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
resetEditFields() {
|
|
||||||
this.editTitle = this.name;
|
|
||||||
this.editDescription = this.place.description || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
if (!this.place.createdAt) return; // Only allow editing saved places
|
||||||
this.resetEditFields();
|
|
||||||
this.isEditing = true;
|
this.isEditing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,28 +40,16 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async saveChanges(event) {
|
async saveChanges(changes) {
|
||||||
event.preventDefault();
|
|
||||||
if (this.args.onSave) {
|
if (this.args.onSave) {
|
||||||
await this.args.onSave({
|
await this.args.onSave({
|
||||||
...this.place,
|
...this.place,
|
||||||
title: this.editTitle,
|
...changes,
|
||||||
description: this.editDescription,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
updateTitle(e) {
|
|
||||||
this.editTitle = e.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateDescription(e) {
|
|
||||||
this.editDescription = e.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get type() {
|
get type() {
|
||||||
const rawType =
|
const rawType =
|
||||||
this.tags.amenity ||
|
this.tags.amenity ||
|
||||||
@@ -171,32 +146,11 @@ export default class PlaceDetails extends Component {
|
|||||||
<template>
|
<template>
|
||||||
<div class="place-details">
|
<div class="place-details">
|
||||||
{{#if this.isEditing}}
|
{{#if this.isEditing}}
|
||||||
<form class="edit-form" {{on "submit" this.saveChanges}}>
|
<PlaceEditForm
|
||||||
<div class="form-group">
|
@place={{this.place}}
|
||||||
<label for="edit-title">Title</label>
|
@onSave={{this.saveChanges}}
|
||||||
<input
|
@onCancel={{this.cancelEditing}}
|
||||||
id="edit-title"
|
/>
|
||||||
type="text"
|
|
||||||
value={{this.editTitle}}
|
|
||||||
{{on "input" this.updateTitle}}
|
|
||||||
class="form-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-desc">Description</label>
|
|
||||||
<textarea
|
|
||||||
id="edit-desc"
|
|
||||||
value={{this.editDescription}}
|
|
||||||
{{on "input" this.updateDescription}}
|
|
||||||
class="form-control"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="edit-actions">
|
|
||||||
<button type="submit" class="btn btn-blue">Save</button>
|
|
||||||
<button type="button" class="btn btn-outline" {{on "click" this.cancelEditing}}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<h3>{{this.name}}</h3>
|
<h3>{{this.name}}</h3>
|
||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
|
|||||||
72
app/components/place-edit-form.gjs
Normal file
72
app/components/place-edit-form.gjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
|
export default class PlaceEditForm extends Component {
|
||||||
|
@tracked title = '';
|
||||||
|
@tracked description = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.title = this.args.place?.title || '';
|
||||||
|
this.description = this.args.place?.description || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.args.onSave) {
|
||||||
|
this.args.onSave({
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateTitle(e) {
|
||||||
|
this.title = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateDescription(e) {
|
||||||
|
this.description = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="edit-title"
|
||||||
|
type="text"
|
||||||
|
value={{this.title}}
|
||||||
|
{{on "input" this.updateTitle}}
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Name of the place"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-desc">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-desc"
|
||||||
|
value={{this.description}}
|
||||||
|
{{on "input" this.updateDescription}}
|
||||||
|
class="form-control"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Add some details..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button type="submit" class="btn btn-blue">Save</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline"
|
||||||
|
{{on "click" @onCancel}}
|
||||||
|
>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -10,6 +10,22 @@ import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
|||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
@action
|
||||||
|
createNewPlace() {
|
||||||
|
const qp = this.router.currentRoute.queryParams;
|
||||||
|
const lat = qp.lat;
|
||||||
|
const lon = qp.lon;
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
||||||
|
} else {
|
||||||
|
// Fallback (shouldn't happen in search context)
|
||||||
|
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
@@ -180,6 +196,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
place.osmTags.tourism
|
place.osmTags.tourism
|
||||||
place.osmTags.leisure
|
place.osmTags.leisure
|
||||||
place.osmTags.historic
|
place.osmTags.historic
|
||||||
|
"Point of Interest"
|
||||||
)}}</div>
|
)}}</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -188,6 +205,15 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty-state">No places found nearby.</p>
|
<p class="empty-state">No places found nearby.</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="create-place-btn"
|
||||||
|
{{on "click" this.createNewPlace}}
|
||||||
|
>
|
||||||
|
<Icon @name="plus" @size={{18}} />
|
||||||
|
Create new place
|
||||||
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export default class Router extends EmberRouter {
|
|||||||
|
|
||||||
Router.map(function () {
|
Router.map(function () {
|
||||||
this.route('place', { path: '/place/:place_id' });
|
this.route('place', { path: '/place/:place_id' });
|
||||||
|
this.route('place.new', { path: '/place/new' });
|
||||||
this.route('search');
|
this.route('search');
|
||||||
});
|
});
|
||||||
|
|||||||
30
app/routes/place/new.js
Normal file
30
app/routes/place/new.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class PlaceNewRoute extends Route {
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
queryParams = {
|
||||||
|
lat: { refreshModel: true },
|
||||||
|
lon: { refreshModel: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
model(params) {
|
||||||
|
return {
|
||||||
|
lat: parseFloat(params.lat),
|
||||||
|
lon: parseFloat(params.lon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
super.setupController(controller, model);
|
||||||
|
if (model.lat && model.lon) {
|
||||||
|
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||||
|
}
|
||||||
|
this.mapUi.startCreating();
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
this.mapUi.stopCreating();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,26 @@ export default class SearchRoute extends Route {
|
|||||||
// Fetch POIs
|
// Fetch POIs
|
||||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
|
// Get cached/saved places in search radius
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add local matches to the list if they aren't already there
|
||||||
|
// We use osmId to deduplicate if possible
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Sort by distance from click
|
// Sort by distance from click
|
||||||
pois = pois
|
pois = pois
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { tracked } from '@glimmer/tracking';
|
|||||||
export default class MapUiService extends Service {
|
export default class MapUiService extends Service {
|
||||||
@tracked selectedPlace = null;
|
@tracked selectedPlace = null;
|
||||||
@tracked isSearching = false;
|
@tracked isSearching = false;
|
||||||
|
@tracked isCreating = false;
|
||||||
|
@tracked creationCoordinates = null;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -15,9 +17,24 @@ export default class MapUiService extends Service {
|
|||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
|
this.isCreating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopSearch() {
|
stopSearch() {
|
||||||
this.isSearching = false;
|
this.isSearching = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startCreating() {
|
||||||
|
this.isCreating = true;
|
||||||
|
this.isSearching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCreating() {
|
||||||
|
this.isCreating = false;
|
||||||
|
this.creationCoordinates = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCreationCoordinates(lat, lon) {
|
||||||
|
this.creationCoordinates = { lat, lon };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,29 +216,54 @@ export default class StorageService extends Service {
|
|||||||
|
|
||||||
async storePlace(placeData) {
|
async storePlace(placeData) {
|
||||||
const savedPlace = await this.places.store(placeData);
|
const savedPlace = await this.places.store(placeData);
|
||||||
// Only append if not already there (handlePlaceChange might also fire)
|
|
||||||
|
// Optimistic Update: Global List
|
||||||
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
|
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
|
||||||
this.savedPlaces = [...this.savedPlaces, savedPlace];
|
this.savedPlaces = [...this.savedPlaces, savedPlace];
|
||||||
|
} else {
|
||||||
|
// Update if exists
|
||||||
|
this.savedPlaces = this.savedPlaces.map((p) =>
|
||||||
|
p.id === savedPlace.id ? savedPlace : p
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic Update: Map View (same logic as Global List)
|
||||||
|
if (!this.placesInView.some((p) => p.id === savedPlace.id)) {
|
||||||
|
this.placesInView = [...this.placesInView, savedPlace];
|
||||||
|
} else {
|
||||||
|
this.placesInView = this.placesInView.map((p) =>
|
||||||
|
p.id === savedPlace.id ? savedPlace : p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return savedPlace;
|
return savedPlace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePlace(placeData) {
|
async updatePlace(placeData) {
|
||||||
const savedPlace = await this.places.store(placeData);
|
const savedPlace = await this.places.store(placeData);
|
||||||
|
|
||||||
// Update local list
|
// Optimistic Update: Global List
|
||||||
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
|
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const newPlaces = [...this.savedPlaces];
|
const newPlaces = [...this.savedPlaces];
|
||||||
newPlaces[index] = savedPlace;
|
newPlaces[index] = savedPlace;
|
||||||
this.savedPlaces = newPlaces;
|
this.savedPlaces = newPlaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Map View
|
||||||
|
this.placesInView = this.placesInView.map((p) =>
|
||||||
|
p.id === savedPlace.id ? savedPlace : p
|
||||||
|
);
|
||||||
|
|
||||||
return savedPlace;
|
return savedPlace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async removePlace(place) {
|
async removePlace(place) {
|
||||||
await this.places.remove(place.id, place.geohash);
|
await this.places.remove(place.id, place.geohash);
|
||||||
|
|
||||||
|
// Update both lists
|
||||||
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
|
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
|
||||||
|
this.placesInView = this.placesInView.filter((p) => p.id !== place.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@@ -649,6 +649,82 @@ span.icon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Map Crosshair for "Create Place" mode */
|
||||||
|
.map-crosshair {
|
||||||
|
position: absolute;
|
||||||
|
/* Default Center */
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #333;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2000;
|
||||||
|
display: none;
|
||||||
|
transition:
|
||||||
|
top 0.3s ease,
|
||||||
|
left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar is open (Desktop: Left 300px) */
|
||||||
|
/* We want to center in the remaining space (width - 300px) */
|
||||||
|
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
||||||
|
/* So shift left by 150px from center */
|
||||||
|
.map-container.sidebar-open .map-crosshair {
|
||||||
|
left: calc(50% + 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||||
|
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||||
|
.map-container.sidebar-open .map-crosshair {
|
||||||
|
left: 50%; /* Reset desktop shift */
|
||||||
|
top: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Helper Text */
|
||||||
|
.helper-text {
|
||||||
|
background: #eef4fc;
|
||||||
|
color: #1a5c9b;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Place Button */
|
||||||
|
.create-place-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-place-btn:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-color: #999;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
// This helps the map know if it should shift the center or adjust view.
|
// This helps the map know if it should shift the center or adjust view.
|
||||||
return (
|
return (
|
||||||
this.router.currentRouteName === 'place' ||
|
this.router.currentRouteName === 'place' ||
|
||||||
|
this.router.currentRouteName === 'place.new' ||
|
||||||
this.router.currentRouteName === 'search'
|
this.router.currentRouteName === 'search'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
83
app/templates/place/new.gjs
Normal file
83
app/templates/place/new.gjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import PlaceEditForm from '#components/place-edit-form';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
export default class PlaceNewTemplate extends Component {
|
||||||
|
@service router;
|
||||||
|
@service storage;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
get initialPlace() {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async savePlace(changes) {
|
||||||
|
try {
|
||||||
|
// Use coordinates from Map UI (which tracks the crosshair center)
|
||||||
|
// Fallback to URL params if map state isn't ready
|
||||||
|
const center = this.mapUi.creationCoordinates || {
|
||||||
|
lat: this.args.model.lat,
|
||||||
|
lon: this.args.model.lon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lat = parseFloat(center.lat.toFixed(6));
|
||||||
|
const lon = parseFloat(center.lon.toFixed(6));
|
||||||
|
|
||||||
|
const placeData = {
|
||||||
|
title: changes.title || 'Untitled Place',
|
||||||
|
description: changes.description,
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
tags: [],
|
||||||
|
osmTags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedPlace = await this.storage.storePlace(placeData);
|
||||||
|
console.log('Created private place:', savedPlace.title);
|
||||||
|
|
||||||
|
// Transition to the new place
|
||||||
|
this.router.replaceWith('place', savedPlace);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create place:', e);
|
||||||
|
alert('Failed to create place: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||||
|
New Place</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
||||||
|
@name="x"
|
||||||
|
@size={{20}}
|
||||||
|
@color="#333"
|
||||||
|
/></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<p class="helper-text">
|
||||||
|
Drag the map to position the crosshair.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PlaceEditForm
|
||||||
|
@place={{this.initialPlace}}
|
||||||
|
@onSave={{this.savePlace}}
|
||||||
|
@onCancel={{this.close}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user