From fc6efa9b8c0bf46e6152da7e3dce686c78ccf8ef Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Feb 2026 17:59:19 +0800 Subject: [PATCH] add field config on location --- .../catalog-realm/components/map-render.gts | 6 +- .../d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json | 72 +++ .../3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json | 60 +++ .../field-spec/geo-point-spec.gts | 335 +++++++++++++ .../field-spec/geo-search-point-spec.gts | 224 +++++++++ packages/catalog-realm/fields/geo-point.gts | 266 +++------- .../components/current-location-addon.gts | 94 ++++ .../components/geo-point-coordinate-input.gts | 114 +++++ .../components/geo-point-edit-field.gts | 101 ++++ .../components/geo-point-map-picker.gts | 146 ++++++ .../components/quick-locations-addon.gts | 74 +++ .../fields/geo-point/util/index.gts | 61 +++ .../catalog-realm/fields/geo-search-point.gts | 464 ++++-------------- .../components/geo-search-address-input.gts | 88 ++++ .../geo-search-point-edit-field.gts | 232 +++++++++ .../geo-search-recent-searches-addon.gts | 79 +++ .../geo-search-top-results-addon.gts | 215 ++++++++ .../fields/geo-search-point/util/index.gts | 50 ++ packages/catalog-realm/route/route.gts | 4 +- 19 files changed, 2123 insertions(+), 562 deletions(-) create mode 100644 packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json create mode 100644 packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json create mode 100644 packages/catalog-realm/field-spec/geo-point-spec.gts create mode 100644 packages/catalog-realm/field-spec/geo-search-point-spec.gts create mode 100644 packages/catalog-realm/fields/geo-point/components/current-location-addon.gts create mode 100644 packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts create mode 100644 packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts create mode 100644 packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts create mode 100644 packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts create mode 100644 packages/catalog-realm/fields/geo-point/util/index.gts create mode 100644 packages/catalog-realm/fields/geo-search-point/components/geo-search-address-input.gts create mode 100644 packages/catalog-realm/fields/geo-search-point/components/geo-search-point-edit-field.gts create mode 100644 packages/catalog-realm/fields/geo-search-point/components/geo-search-recent-searches-addon.gts create mode 100644 packages/catalog-realm/fields/geo-search-point/components/geo-search-top-results-addon.gts create mode 100644 packages/catalog-realm/fields/geo-search-point/util/index.gts diff --git a/packages/catalog-realm/components/map-render.gts b/packages/catalog-realm/components/map-render.gts index 803ea84e50c..b511c799378 100644 --- a/packages/catalog-realm/components/map-render.gts +++ b/packages/catalog-realm/components/map-render.gts @@ -88,7 +88,7 @@ export class MapRender extends GlimmerComponent { margin: 0; width: 100%; height: 100%; - min-height: 300px; + aspect-ratio: 16/9; position: relative; display: flex; align-items: center; @@ -198,7 +198,9 @@ class LeafletLayerState implements LeafletLayerStateInterface { const color = i === 0 ? '#22c55e' : i === coords.length - 1 ? '#ef4444' : '#3b82f6'; const marker = createMarker(c, color); - if (c.address) marker.bindPopup(c.address); + const popupContent = + c.address?.trim() || `${c.lat.toFixed(6)}, ${c.lng.toFixed(6)}`; + marker.bindPopup(popupContent); return marker; }); } diff --git a/packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json b/packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json new file mode 100644 index 00000000000..6d8173d2636 --- /dev/null +++ b/packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json @@ -0,0 +1,72 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "GeoPointFieldSpec", + "module": "../geo-point-spec" + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "default", + "module": "../../fields/geo-point" + }, + "basic": { + "lat": 35.713552518550046, + "lon": 139.78250909596682 + }, + "readMe": null, + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "combined": { + "lat": 3.1686073220230058, + "lon": 101.53343932757683 + }, + "specType": "field", + "cardTitle": "Geo Point Field", + "mapPicker": { + "lat": 40.75080629178881, + "lon": -74.03073135763408 + }, + "cardDescription": "Configurable GeoPointField spec used by the playground.", + "containedExamples": [], + "withQuickLocations": { + "lat": 48.8534951, + "lon": 2.3483915 + }, + "mapPickerWithAddons": { + "lat": 40.7127281, + "lon": -74.0060152 + }, + "withCurrentLocation": { + "lat": 3.1685946003187757, + "lon": 101.53342388429286 + }, + "mapPickerWithQuickLocations": { + "lat": 48.8534951, + "lon": 2.3483915 + }, + "mapPickerWithCurrentLocation": { + "lat": 3.168046434513096, + "lon": 101.53263743966819 + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json b/packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json new file mode 100644 index 00000000000..2db9ba2f8e8 --- /dev/null +++ b/packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json @@ -0,0 +1,60 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "GeoSearchPointFieldSpec", + "module": "../geo-search-point-spec" + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "default", + "module": "../../fields/geo-search-point" + }, + "basic": { + "lat": 3.1336269, + "lon": 101.6299203, + "searchKey": "Glo damansara" + }, + "readMe": null, + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "combined": { + "lat": 3.1516964, + "lon": 101.6942371, + "searchKey": "Kuala Lumpur, 50100, Malaysia" + }, + "specType": "field", + "cardTitle": "Geo Search Point Field", + "withTopResults": { + "lat": 3.1414907, + "lon": 101.7182597, + "searchKey": "Tun Razak Exchange (TRX), Pudu, Kuala Lumpur, 55188, Malaysia" + }, + "cardDescription": "Spec card that renders GeoSearchPointField examples.", + "containedExamples": [], + "withoutRecentSearches": { + "lat": 48.8534951, + "lon": 2.3483915, + "searchKey": "Paris, Ile-de-France, Metropolitan France, France" + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/field-spec/geo-point-spec.gts b/packages/catalog-realm/field-spec/geo-point-spec.gts new file mode 100644 index 00000000000..c1e2060fb4c --- /dev/null +++ b/packages/catalog-realm/field-spec/geo-point-spec.gts @@ -0,0 +1,335 @@ +import { + Spec, + SpecHeader, + SpecReadmeSection, + ExamplesWithInteractive, + SpecModuleSection, +} from 'https://cardstack.com/base/spec'; +import { + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; +import GeoPointField from '../fields/geo-point'; +import CodeSnippet from '../components/code-snippet'; + +// 1. Basic standard (no config needed) +const basicFieldCode = `@field basic = contains(GeoPointField);`; + +// 2. With current location tracker +const withCurrentLocationFieldCode = `@field withCurrentLocation = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + }, + }, +});`; + +// 3. With quick locations +const withQuickLocationsFieldCode = `@field withQuickLocations = contains(GeoPointField, { + configuration: { + options: { + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +// 4. Combined: current location + quick locations +const combinedFieldCode = `@field combined = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +// 5. Map picker variant (no options) +const mapPickerFieldCode = `@field mapPicker = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + }, +});`; + +// 6. Map picker with showCurrentLocation (MapPickerOptions) +const mapPickerWithCurrentLocationFieldCode = `@field mapPickerWithCurrentLocation = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + showCurrentLocation: true, + }, + }, +});`; + +// 7. Map picker with quickLocations (MapPickerOptions) +const mapPickerWithQuickLocationsFieldCode = `@field mapPickerWithQuickLocations = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +// 8. Map picker with both addons + map options (MapPickerOptions) +const mapPickerWithAddonsFieldCode = `@field mapPickerWithAddons = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + tileserverUrl: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + mapHeight: '300px', + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +class GeoPointFieldSpecIsolated extends Component { + +} + +class GeoPointFieldSpecEdit extends Component { + +} + +export class GeoPointFieldSpec extends Spec { + static displayName = 'Geo Point Field Spec'; + + // 1. Basic standard (no config) + @field basic = contains(GeoPointField); + + // 2. With current location tracker + @field withCurrentLocation = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + }, + }, + }); + + // 3. With quick locations + @field withQuickLocations = contains(GeoPointField, { + configuration: { + options: { + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + // 4. Combined: current location + quick locations + @field combined = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + // 5. Map picker variant (no options) + @field mapPicker = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + }, + }); + + // 6. Map picker with showCurrentLocation (using MapPickerOptions) + @field mapPickerWithCurrentLocation = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + showCurrentLocation: true, + }, + }, + }); + + // 7. Map picker with quickLocations (using MapPickerOptions) + @field mapPickerWithQuickLocations = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + // 8. Map picker with both addons + map options (using MapPickerOptions) + @field mapPickerWithAddons = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + tileserverUrl: + 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + mapHeight: '300px', + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + static isolated = + GeoPointFieldSpecIsolated as unknown as typeof Spec.isolated; + static edit = GeoPointFieldSpecEdit as unknown as typeof Spec.edit; +} diff --git a/packages/catalog-realm/field-spec/geo-search-point-spec.gts b/packages/catalog-realm/field-spec/geo-search-point-spec.gts new file mode 100644 index 00000000000..898ec4761c1 --- /dev/null +++ b/packages/catalog-realm/field-spec/geo-search-point-spec.gts @@ -0,0 +1,224 @@ +import { + Spec, + SpecHeader, + SpecReadmeSection, + ExamplesWithInteractive, + SpecModuleSection, +} from 'https://cardstack.com/base/spec'; +import { + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; +import GeoSearchPointField from '../fields/geo-search-point'; +import CodeSnippet from '../components/code-snippet'; + +// 1. Basic (no config) +const basicFieldCode = `@field basic = contains(GeoSearchPointField);`; + +// 2. With top search results +const withTopResultsCode = `@field withTopResults = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + }, + }, +});`; + +// 3. Top results without recent searches +const withoutRecentSearchesCode = `@field withoutRecentSearches = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + showRecentSearches: false, + }, + }, +});`; + +// 4. Combined: all features +const combinedCode = `@field combined = contains(GeoSearchPointField, { + configuration: { + options: { + placeholder: 'Start typing an address...', + showTopSearchResults: true, + topSearchResultsLimit: 5, + recentSearchesLimit: 5, + }, + }, +});`; + +class GeoSearchPointFieldSpecIsolated extends Component< + typeof GeoSearchPointFieldSpec +> { + +} + +class GeoSearchPointFieldSpecEdit extends Component< + typeof GeoSearchPointFieldSpec +> { + +} + +export class GeoSearchPointFieldSpec extends Spec { + static displayName = 'Geo Search Point Field Spec'; + + // 1. Basic (no config) + @field basic = contains(GeoSearchPointField); + + // 2. With top search results + @field withTopResults = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + }, + }, + }); + + // 3. Top results without recent searches + @field withoutRecentSearches = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + showRecentSearches: false, + }, + }, + }); + + // 4. Combined: all features + @field combined = contains(GeoSearchPointField, { + configuration: { + options: { + placeholder: 'Start typing an address...', + showTopSearchResults: true, + topSearchResultsLimit: 5, + recentSearchesLimit: 5, + }, + }, + }); + + static isolated = + GeoSearchPointFieldSpecIsolated as unknown as typeof Spec.isolated; + static edit = GeoSearchPointFieldSpecEdit as unknown as typeof Spec.edit; +} diff --git a/packages/catalog-realm/fields/geo-point.gts b/packages/catalog-realm/fields/geo-point.gts index 16e6b720e40..3303e2b187f 100644 --- a/packages/catalog-realm/fields/geo-point.gts +++ b/packages/catalog-realm/fields/geo-point.gts @@ -5,229 +5,103 @@ import { field, } from 'https://cardstack.com/base/card-api'; import NumberField from 'https://cardstack.com/base/number'; -import { action } from '@ember/object'; -import MapIcon from '@cardstack/boxel-icons/map'; import MapPinIcon from '@cardstack/boxel-icons/map-pin'; -import { FieldContainer } from '@cardstack/boxel-ui/components'; -import { MapRender } from '../components/map-render'; -class AtomTemplate extends Component { - get displayValue() { - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +import GeoPointEditField from './geo-point/components/geo-point-edit-field'; +import GeoPointMapPicker from './geo-point/components/geo-point-map-picker'; - if (lat != null && lon != null) { - return `${lat}, ${lon}`; - } - return 'No coordinates'; - } +// --- Configuration Types --- - +export interface GeoPointMapOptions { + tileserverUrl?: string; + mapHeight?: string; } -class EmbeddedTemplate extends Component { - get displayValue() { - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +export type GeoPointVariant = 'standard' | 'map-picker'; - if (lat != null && lon != null) { - return `${lat}, ${lon}`; +export type GeoPointConfiguration = + | { + variant?: 'standard'; + options?: GeoPointBaseOptions; } - return 'No coordinates'; - } + | { + variant?: 'map-picker'; + options?: GeoPointBaseOptions & GeoPointMapOptions; + }; - get coordinates() { - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +// --- Dispatcher Templates --- - if (lat != null && lon != null) { - return [{ lat, lng: lon }]; - } - return []; +export class GeoPointEdit extends Component { + +} + +export class GeoPointEmbedded extends Component { + get config(): GeoPointConfiguration { + return (this.args.configuration as GeoPointConfiguration) ?? {}; } - get hasValidCoordinates() { - return this.args.model?.lat != null && this.args.model?.lon != null; + get mapOptions() { + const opts = this.config.options; + if (!opts) return undefined; + return { + mapHeight: (opts as GeoPointMapOptions).mapHeight, + tileserverUrl: (opts as GeoPointMapOptions).tileserverUrl, + }; } - @action - updateCoordinate(coordinate: { lat: number; lng: number }) { - if (this.args.model) { - this.args.model.lat = coordinate.lat; - this.args.model.lon = coordinate.lng; + +} + +export class GeoPointAtom extends Component { + get displayValue(): string { + const { lat, lon } = this.args.model; + if (lat != null && lon != null) { + return `${lat}, ${lon}`; } + return 'No location'; } } -class EditTemplate extends Component { - -} +// --- Field Definition --- export default class GeoPointField extends FieldDef { static displayName = 'Geo Point'; @@ -236,7 +110,7 @@ export default class GeoPointField extends FieldDef { @field lat = contains(NumberField); @field lon = contains(NumberField); - static atom = AtomTemplate; - static embedded = EmbeddedTemplate; - static edit = EditTemplate; + static atom = GeoPointAtom; + static embedded = GeoPointEmbedded; + static edit = GeoPointEdit; } diff --git a/packages/catalog-realm/fields/geo-point/components/current-location-addon.gts b/packages/catalog-realm/fields/geo-point/components/current-location-addon.gts new file mode 100644 index 00000000000..fd4be96ad0f --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/current-location-addon.gts @@ -0,0 +1,94 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { BoxelButton } from '@cardstack/boxel-ui/components'; +import { not } from '@cardstack/boxel-ui/helpers'; +import { task } from 'ember-concurrency'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; +import type { GeoModel } from '../util/index'; + +interface CurrentLocationAddonSignature { + Args: { + model: GeoModel; + canEdit?: boolean; + }; +} + +function getPosition(): Promise { + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + timeout: 10000, + maximumAge: 0, + }); + }); +} + +export default class CurrentLocationAddon extends GlimmerComponent { + private locateTask = task(async () => { + if (!navigator.geolocation) { + throw new Error('Geolocation is not supported by this browser.'); + } + const position = await getPosition(); + if (this.args.model) { + this.args.model.lat = position.coords.latitude; + this.args.model.lon = position.coords.longitude; + } + }); + + get errorMessage(): string | null { + const error = this.locateTask.last?.error; + if (!error) return null; + return error instanceof GeolocationPositionError + ? `Location error: ${error.message}` + : (error as Error).message; + } + + @action + getCurrentLocation() { + this.locateTask.perform(); + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts b/packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts new file mode 100644 index 00000000000..855bf0d6f8a --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts @@ -0,0 +1,114 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { not } from '@cardstack/boxel-ui/helpers'; +import { FieldContainer, BoxelInput } from '@cardstack/boxel-ui/components'; +import type { GeoModel } from '../util/index'; + +interface CoordinateInputSignature { + Args: { + model: GeoModel; + canEdit?: boolean; + }; +} + +export default class GeoPointCoordinateInput extends GlimmerComponent { + get latState(): 'valid' | 'invalid' | 'none' { + const lat = this.args.model.lat; + if (lat == null) return 'none'; + return lat >= -90 && lat <= 90 ? 'valid' : 'invalid'; + } + + get latError(): string | undefined { + return this.latState === 'invalid' + ? 'Latitude must be between -90 and 90' + : undefined; + } + + get lonState(): 'valid' | 'invalid' | 'none' { + const lon = this.args.model.lon; + if (lon == null) return 'none'; + return lon >= -180 && lon <= 180 ? 'valid' : 'invalid'; + } + + get lonError(): string | undefined { + return this.lonState === 'invalid' + ? 'Longitude must be between -180 and 180' + : undefined; + } + + @action + setLat(val: string) { + if (this.args.model) { + this.args.model.lat = val === '' ? null : Number(val); + } + } + + @action + setLon(val: string) { + if (this.args.model) { + this.args.model.lon = val === '' ? null : Number(val); + } + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts b/packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts new file mode 100644 index 00000000000..1ca23485f98 --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts @@ -0,0 +1,101 @@ +import GlimmerComponent from '@glimmer/component'; +import { eq, or } from '@cardstack/boxel-ui/helpers'; +import type { GeoModel } from '../util/index'; +import type { + GeoPointConfiguration, + GeoPointMapOptions, + GeoPointVariant, +} from '../../geo-point'; +import GeoPointCoordinateInput from './geo-point-coordinate-input'; +import GeoPointMapPicker from './geo-point-map-picker'; +import CurrentLocationAddon from './current-location-addon'; +import QuickLocationsAddon from './quick-locations-addon'; + +interface GeoPointEditFieldSignature { + Args: { + model: GeoModel; + canEdit?: boolean; + configuration?: GeoPointConfiguration; + }; +} + +export default class GeoPointEditField extends GlimmerComponent { + get variant(): GeoPointVariant { + return this.args.configuration?.variant ?? 'standard'; + } + + private get options() { + return this.args.configuration?.options ?? {}; + } + + get showCurrentLocation(): boolean { + return this.options.showCurrentLocation ?? false; + } + + get quickLocations(): string[] { + return this.options.quickLocations ?? []; + } + + get hasQuickLocations(): boolean { + return this.quickLocations.length > 0; + } + + get mapOptions(): GeoPointMapOptions | undefined { + const config = this.args.configuration; + if (config?.variant !== 'map-picker') return undefined; + const opts = config.options; + return opts + ? { tileserverUrl: opts.tileserverUrl, mapHeight: opts.mapHeight } + : undefined; + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts b/packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts new file mode 100644 index 00000000000..d3af4d90ef9 --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts @@ -0,0 +1,146 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; +import { MapRender, type Coordinate } from '../../../components/map-render'; +import { hasValidCoordinates, formatCoordinates } from '../util/index'; +import type { GeoModel } from '../util/index'; +import type { GeoPointMapOptions } from '../../geo-point'; + +interface MapPickerSignature { + Args: { + model: GeoModel; + options?: GeoPointMapOptions; + canEdit?: boolean; + }; +} + +export default class GeoPointMapPicker extends GlimmerComponent { + get hasCoordinates() { + return hasValidCoordinates(this.args.model); + } + + get coordinates(): Coordinate[] { + if (!this.hasCoordinates) return []; + return [{ lat: this.args.model.lat!, lng: this.args.model.lon! }]; + } + + get coordinateDisplay() { + if (this.hasCoordinates) { + return formatCoordinates(this.args.model.lat, this.args.model.lon); + } + return this.args.canEdit + ? 'Click the map to place a pin' + : 'No location set'; + } + + get mapConfig(): { + tileserverUrl?: string; + disableMapClick?: boolean; + } { + const config: { + tileserverUrl?: string; + disableMapClick?: boolean; + } = {}; + if (this.args.options?.tileserverUrl) { + config.tileserverUrl = this.args.options.tileserverUrl; + } + if (!this.args.canEdit) { + config.disableMapClick = true; + } + return config; + } + + get mapContainerStyle() { + const height = this.args.options?.mapHeight; + return height ? htmlSafe(`height: ${height}`) : undefined; + } + + @action + handleMapClick(coordinate: Coordinate) { + if (this.args.canEdit && this.args.model) { + this.args.model.lat = coordinate.lat; + this.args.model.lon = coordinate.lng; + } + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts b/packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts new file mode 100644 index 00000000000..9c0061c986c --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts @@ -0,0 +1,74 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { tracked } from '@glimmer/tracking'; +import { eq, not } from '@cardstack/boxel-ui/helpers'; +import { BoxelButton, FieldContainer } from '@cardstack/boxel-ui/components'; +import { task } from 'ember-concurrency'; +import type { GeoModel } from '../util/index'; +import { geocodeLocation } from '../util/index'; + +interface QuickLocationsAddonSignature { + Args: { + model: GeoModel; + locations: string[]; + canEdit?: boolean; + }; +} + +export default class QuickLocationsAddon extends GlimmerComponent { + @tracked loadingLocation: string | null = null; + + private geocodeAndSet = task(async (locationName: string) => { + this.loadingLocation = locationName; + try { + const result = await geocodeLocation(locationName); + if (result && this.args.model) { + this.args.model.lat = result.lat; + this.args.model.lon = result.lon; + } + } catch (error) { + console.error('Failed to geocode location:', error); + } finally { + this.loadingLocation = null; + } + }); + + @action + selectQuickLocation(locationName: string) { + this.geocodeAndSet.perform(locationName); + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/util/index.gts b/packages/catalog-realm/fields/geo-point/util/index.gts new file mode 100644 index 00000000000..cdd2e329321 --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/util/index.gts @@ -0,0 +1,61 @@ +export interface GeoModel { + lat?: number | null; + lon?: number | null; + searchKey?: string | null; +} + +export function hasValidCoordinates(model: GeoModel | null | undefined): boolean { + if (!model) return false; + const { lat, lon } = model; + return ( + lat != null && + lon != null && + typeof lat === 'number' && + typeof lon === 'number' && + !isNaN(lat) && + !isNaN(lon) + ); +} + +export function formatCoordinates( + lat: number | null | undefined, + lon: number | null | undefined, + precision?: number, +): string { + if (lat == null || lon == null) return 'No coordinates'; + const p = precision ?? 6; + return `${lat.toFixed(p)}, ${lon.toFixed(p)}`; +} + +export async function geocodeLocation( + query: string, +): Promise<{ lat: number; lon: number } | null> { + if (!query || query.trim() === '') return null; + + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`, + ); + + if (!response.ok) { + throw new Error(`Geocoding failed: ${response.status}`); + } + + const data = await response.json(); + if (!data || data.length === 0) return null; + + const lat = parseFloat(data[0].lat); + const lon = parseFloat(data[0].lon); + + if ( + isNaN(lat) || + isNaN(lon) || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) { + return null; + } + + return { lat, lon }; +} diff --git a/packages/catalog-realm/fields/geo-search-point.gts b/packages/catalog-realm/fields/geo-search-point.gts index e8113608564..25991a08ca6 100644 --- a/packages/catalog-realm/fields/geo-search-point.gts +++ b/packages/catalog-realm/fields/geo-search-point.gts @@ -1,427 +1,167 @@ -import { action } from '@ember/object'; -import { task } from 'ember-concurrency'; -import { debounce } from 'lodash'; -import { BoxelInput } from '@cardstack/boxel-ui/components'; -import MapIcon from '@cardstack/boxel-icons/map'; -import StringField from 'https://cardstack.com/base/string'; import { Component, contains, field, } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; import GeoPointField from './geo-point'; -import { MapRender, type Coordinate } from '../components/map-render'; +import GeoSearchPointEditField from './geo-search-point/components/geo-search-point-edit-field'; +import GeoPointMapPicker from './geo-point/components/geo-point-map-picker'; -class AtomTemplate extends Component { - get displayValue() { - const address = this.args.model?.searchKey; - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +// --- Configuration Types --- - if (address && address.trim() !== '') { - return address; - } +interface GeoSearchPointCommonOptions { + placeholder?: string; + tileserverUrl?: string; + mapHeight?: string; +} - if (lat != null && lon != null) { - return `${lat}, ${lon}`; - } +interface GeoSearchPointWithSearchResults extends GeoSearchPointCommonOptions { + showTopSearchResults: true; + topSearchResultsLimit?: number; + showRecentSearches?: boolean; + recentSearchesLimit?: number; +} - return 'No location'; - } +interface GeoSearchPointWithoutSearchResults extends GeoSearchPointCommonOptions { + showTopSearchResults?: false; +} -