added new geoPoint field

This commit is contained in:
Gani Georgiev
2025-04-02 11:38:19 +03:00
parent f3a836eb7c
commit 4c5abd5bd9
60 changed files with 1373 additions and 1143 deletions
+233
View File
@@ -0,0 +1,233 @@
<script>
import { onMount } from "svelte";
import tooltip from "@/actions/tooltip";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// manually load the markers so that they can be embedded in the prod bundle
import markerIconUrl from "leaflet/dist/images/marker-icon.png";
import markerIconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
import markerShadowUrl from "leaflet/dist/images/marker-shadow.png";
export let height = 225;
export let point = { lat: 0, lon: 0 };
let map;
let mapEl;
let marker;
let isSearching = false;
let searchTerm = "";
let searchResults = [];
let searchTimeoutId;
let searchAbortController;
let panTimeoutId;
const defaultZoomLevel = 8;
$: search(searchTerm);
$: if (point.lat && point.lon) {
panInside();
}
function normalizeCoordinate(coord) {
return +(+coord).toFixed(6);
}
function panInside(debounce = 200) {
clearTimeout(panTimeoutId);
panTimeoutId = setTimeout(() => {
marker?.setLatLng([point.lat, point.lon]);
map?.panInside([point.lat, point.lon], { padding: [20, 40] });
}, debounce);
}
function initMap() {
const latlon = [normalizeCoordinate(point.lat), normalizeCoordinate(point.lon)];
map = L.map(mapEl, { zoomControl: false }).setView(latlon, defaultZoomLevel);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
// reassign the default marker images with the loaded ones
// (https://leafletjs.com/reference.html#icon-default-option)
L.Icon.Default.prototype.options.iconUrl = markerIconUrl;
L.Icon.Default.prototype.options.iconRetinaUrl = markerIconRetinaUrl;
L.Icon.Default.prototype.options.shadowUrl = markerShadowUrl;
L.Icon.Default.imagePath = "";
marker = L.marker(latlon, {
draggable: true,
autoPan: true,
}).addTo(map);
marker.bindTooltip("drag or right click anywhere on the map to move");
marker.on("moveend", (e) => {
if (e.sourceTarget?._latlng) {
select(e.sourceTarget._latlng.lat, e.sourceTarget._latlng.lng, false);
}
});
map.on("contextmenu", (e) => {
select(e.latlng.lat, e.latlng.lng, false);
});
}
function destroyMap() {
resetSearch();
marker?.remove();
map?.remove();
}
function resetSearch() {
searchAbortController?.abort();
clearTimeout(searchTimeoutId);
isSearching = false;
searchResults = [];
searchTerm = "";
}
// note: using debounce > 1s to minimize hitting the API rate limits
// (see also https://operations.osmfoundation.org/policies/nominatim/)
function search(q, debounce = 1100) {
isSearching = true;
searchResults = [];
clearTimeout(searchTimeoutId);
searchAbortController?.abort();
if (!q) {
isSearching = false;
return;
}
searchTimeoutId = setTimeout(async () => {
searchAbortController = new AbortController();
try {
const response = await fetch(
"https://nominatim.openstreetmap.org/search.php?format=jsonv2&q=" + encodeURIComponent(q),
{ signal: searchAbortController.signal },
);
if (response.status != 200) {
throw new Error("OpenStreetMap API error " + response.status);
}
const addresses = await response.json();
for (const item of addresses) {
searchResults.push({
lat: item.lat,
lon: item.lon,
name: item.display_name,
});
}
} catch (err) {
console.warn("[address search failed]", err);
}
searchResults = searchResults;
isSearching = false;
}, debounce);
}
function select(lat, lon, centerMap = true) {
point.lat = normalizeCoordinate(lat);
point.lon = normalizeCoordinate(lon);
// center the map
if (centerMap) {
marker?.setLatLng([point.lat, point.lon]); // optimistic marker update
map?.panTo([point.lat, point.lon], { animate: false });
}
resetSearch();
}
onMount(() => {
initMap();
return () => {
destroyMap();
};
});
</script>
<div class="map-wrapper" style="{height ? `height:${height}px` : null};">
<div class="map-search">
<div class="form-field m-0">
{#if isSearching}
<div class="form-field-addon">
<span class="loader loader-xs"></span>
</div>
{:else if searchTerm.length}
<div class="form-field-addon">
<button
type="button"
class="btn btn-circle btn-xs btn-transparent"
on:click={resetSearch}
>
<i class="ri-close-line"></i>
</button>
</div>
{/if}
<input type="text" placeholder="Search address..." bind:value={searchTerm} />
</div>
{#if searchTerm.length && searchResults.length}
<div class="dropdown dropdown-sm dropdown-block">
{#each searchResults as result, i}
<button
type="button"
class="dropdown-item"
use:tooltip={"Select address coordinates"}
on:click={() => select(result.lat, result.lon)}
>
{result.name}
</button>
{/each}
</div>
{/if}
</div>
<div bind:this={mapEl} class="map-box"></div>
</div>
<style lang="scss">
.map-wrapper {
position: relative;
display: block;
height: 100%;
width: 100%;
}
.map-box {
z-index: 1;
height: 100%;
width: 100%;
}
.map-search {
position: absolute;
z-index: 9999;
top: 10px;
width: 70%;
max-width: 400px;
margin-left: 15%;
height: auto;
input {
opacity: 0.7;
background: var(--baseColor);
border: 0;
box-shadow: 0 0 3px 0 var(--shadowColor);
transition: opacity var(--baseAnimationSpeed);
}
.dropdown {
max-height: 150px;
border: 0;
box-shadow: 0 0 3px 0 var(--shadowColor);
}
&:focus-within {
input {
opacity: 1;
}
}
}
</style>
@@ -15,6 +15,7 @@
import SchemaFieldSelect from "@/components/collections/schema/SchemaFieldSelect.svelte";
import SchemaFieldText from "@/components/collections/schema/SchemaFieldText.svelte";
import SchemaFieldUrl from "@/components/collections/schema/SchemaFieldUrl.svelte";
import SchemaFieldGeoPoint from "@/components/collections/schema/SchemaFieldGeoPoint.svelte";
import { scaffolds } from "@/stores/collections";
import { setErrors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
@@ -37,6 +38,7 @@
relation: SchemaFieldRelation,
password: SchemaFieldPassword,
autodate: SchemaFieldAutodate,
geoPoint: SchemaFieldGeoPoint,
};
$: if (!collection.id && oldCollectionType != collection.type) {
@@ -1,4 +1,12 @@
<script>
import { createEventDispatcher, tick } from "svelte";
import { scale } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { confirm } from "@/stores/confirmation";
import { errors, removeError, setErrors } from "@/stores/errors";
import { addSuccessToast, removeAllToasts } from "@/stores/toasts";
import { addCollection, removeCollection, scaffolds, activeCollection } from "@/stores/collections";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
@@ -8,14 +16,6 @@
import CollectionQueryTab from "@/components/collections/CollectionQueryTab.svelte";
import CollectionRulesTab from "@/components/collections/CollectionRulesTab.svelte";
import CollectionUpdateConfirm from "@/components/collections/CollectionUpdateConfirm.svelte";
import { addCollection, removeCollection, scaffolds, activeCollection } from "@/stores/collections";
import { confirm } from "@/stores/confirmation";
import { errors, removeError, setErrors } from "@/stores/errors";
import { addSuccessToast, removeAllToasts } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher, tick } from "svelte";
import { scale } from "svelte/transition";
const TAB_SCHEMA = "schema";
const TAB_RULES = "api_rules";
@@ -40,7 +40,7 @@
icon: CommonHelper.getFieldTypeIcon("url"),
},
{
label: "DateTime",
label: "Datetime",
value: "date",
icon: CommonHelper.getFieldTypeIcon("date"),
},
@@ -69,6 +69,11 @@
value: "json",
icon: CommonHelper.getFieldTypeIcon("json"),
},
{
label: "Geo Point",
value: "geoPoint",
icon: CommonHelper.getFieldTypeIcon("geoPoint"),
},
// {
// label: "Password",
// value: "password",
@@ -0,0 +1,8 @@
<script>
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
</script>
<SchemaField bind:field {key} on:rename on:remove on:duplicate {...$$restProps} />
@@ -5,6 +5,7 @@
import TinyMCE from "@/components/base/TinyMCE.svelte";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
import GeoPointValue from "@/components/records/fields/GeoPointValue.svelte";
import { superuser } from "@/stores/superuser";
import CommonHelper from "@/utils/CommonHelper";
@@ -120,6 +121,8 @@
...
{/if}
</div>
{:else if field.type === "geoPoint"}
<div class="label"><GeoPointValue value={rawValue} /></div>
{:else if short}
<span class="txt txt-ellipsis" title={CommonHelper.truncate(rawValue)}>
{CommonHelper.truncate(rawValue)}
@@ -1,8 +1,9 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfoContent from "@/components/records/RecordInfoContent.svelte";
import GeoPointValue from "@/components/records/fields/GeoPointValue.svelte";
import { collections } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
export let record;
@@ -53,6 +54,8 @@
{#if field.type == "relation" && record.expand?.[field.name]}
<RecordInfoContent bind:record={record.expand[field.name]} />
{:else if field.type == "geoPoint"}
<GeoPointValue value={record[field.name]} />
{:else}
{CommonHelper.truncate(CommonHelper.displayValue(record, [field.name]), 70)}
{/if}
@@ -23,6 +23,7 @@
import SelectField from "@/components/records/fields/SelectField.svelte";
import TextField from "@/components/records/fields/TextField.svelte";
import UrlField from "@/components/records/fields/UrlField.svelte";
import GeoPointField from "@/components/records/fields/GeoPointField.svelte";
import ImpersonatePopup from "@/components/records/ImpersonatePopup.svelte";
import { confirm } from "@/stores/confirmation";
import { setErrors } from "@/stores/errors";
@@ -730,6 +731,8 @@
<RelationField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "password"}
<PasswordField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "geoPoint"}
<GeoPointField {field} {original} {record} bind:value={record[field.name]} />
{/if}
{/each}
</form>
@@ -0,0 +1,143 @@
<script>
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
import { slide } from "svelte/transition";
export let original;
export let field;
export let value = undefined;
let mapComponent;
let isMapComponentLoading = false;
let isMapVisible = false;
$: if (typeof value === "undefined") {
value = { lat: 0, lon: 0 };
}
$: if (value) {
normalize();
}
function normalize() {
if (value.lat > 90) {
value.lat = 90;
}
if (value.lat < -90) {
value.lat = -90;
}
if (value.lon > 180) {
value.lon = 180;
}
if (value.lon < -180) {
value.lon = -180;
}
}
function toggleMapVisibility() {
if (isMapVisible) {
hideMap();
} else {
showMap();
}
}
function showMap() {
loadMapComponent();
isMapVisible = true;
}
function hideMap() {
isMapVisible = false;
}
async function loadMapComponent() {
if (mapComponent || isMapComponentLoading) {
return; // already loaded or in the process
}
isMapComponentLoading = true;
mapComponent = (await import("@/components/base/Leaflet.svelte")).default;
isMapComponentLoading = false;
}
</script>
<Field class="form-field form-field-list {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<FieldLabel {uniqueId} {field} />
<div class="list">
<div class="list-item">
<Field class="form-field form-field-inline m-0" let:uniqueId>
<label for={uniqueId}>Longitude:</label>
<input
type="number"
id={uniqueId}
required={field.required}
placeholder="0"
step="any"
min="-180"
max="180"
bind:value={value.lon}
/>
</Field>
<span class="separator"></span>
<Field class="form-field form-field-inline m-0" let:uniqueId>
<label for={uniqueId}>Latitude:</label>
<input
type="number"
id={uniqueId}
required={field.required}
placeholder="0"
step="any"
min="-90"
max="90"
bind:value={value.lat}
/>
</Field>
<span class="separator"></span>
<button
type="button"
class="btn btn-circle btn-sm btn-circle {isMapVisible
? 'btn-secondary'
: 'btn-hint btn-transparent'}"
aria-label="Toggle map"
use:tooltip={"Toggle map"}
on:click={toggleMapVisibility}
>
<i class="ri-map-2-line"></i>
</button>
</div>
{#if isMapVisible}
<div class="block" style="height:200px" transition:slide={{ duration: 150 }}>
{#if isMapComponentLoading}
<div class="block txt-center p-base">
<span class="loader loader-sm"></span>
</div>
{:else}
<svelte:component this={mapComponent} height={200} bind:point={value} />
{/if}
</div>
{/if}
</div>
</Field>
<style lang="scss">
.list-item {
padding: 5px 10px;
min-height: 0;
gap: 10px;
}
.separator {
align-self: stretch;
background: var(--baseAlt2Color);
width: 1px;
margin: -5px 0;
}
</style>
@@ -0,0 +1,9 @@
<script>
export let value = {};
</script>
<div class="txt">
{value?.lon}
<span class="txt-disabled txt-xs">|</span>
{value.lat}
</div>
+35 -5
View File
@@ -524,7 +524,7 @@ select {
border-top-left-radius: var(--baseRadius);
border-top-right-radius: var(--baseRadius);
& ~ %input,
& ~ div %input {
& ~ div > %input {
border-top: 0;
padding-top: 2px;
padding-bottom: 8px;
@@ -545,7 +545,7 @@ select {
background var(--baseAnimationSpeed),
box-shadow var(--baseAnimationSpeed);
}
&:focus-within {
&:focus-within:not(.form-field-list) {
%input, label {
background: var(--baseAlt2Color);
}
@@ -661,7 +661,7 @@ select {
border: 0;
margin: 0;
outline: 0;
background: none;
background: none !important;
display: inline-flex;
vertical-align: top;
align-items: center;
@@ -883,6 +883,33 @@ select {
}
}
.form-field-inline {
display: flex;
width: 100%;
align-items: stretch;
> label {
height: auto;
width: auto;
margin: 0;
padding: 0 5px 0 10px;
padding-bottom: 0;
white-space: nowrap;
border-top-left-radius: var(--baseRadius);
border-top-right-radius: 0;
border-bottom-left-radius: var(--baseRadius);
border-bottom-right-radius: 0;
~ input {
padding-left: 5px;
padding-top: 0;
padding-bottom: 0;
border-top-left-radius: 0;
border-top-right-radius: var(--baseRadius);
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--baseRadius);
}
}
}
// select field
.select {
position: relative;
@@ -1155,7 +1182,7 @@ select {
.form-field-list {
border-radius: var(--baseRadius);
transition: box-shadow var(--baseAnimationSpeed);
label {
> label {
padding-bottom: 10px;
}
.list {
@@ -1196,9 +1223,12 @@ select {
}
}
&:focus-within {
.list, label {
.list, %input:not(:focus), > label {
background: var(--baseAlt1Color);
}
> label {
color: var(--txtPrimaryColor);
}
}
&.dragover:not(:has(.dragging)) {
box-shadow: 0px 0px 0px 2px var(--warningColor);
+9 -2
View File
@@ -1161,7 +1161,7 @@ export default class CommonHelper {
* @return {String}
*/
static getFieldTypeIcon(type) {
switch (type?.toLowerCase()) {
switch (type) {
case "primary":
return "ri-key-line";
case "text":
@@ -1190,6 +1190,8 @@ export default class CommonHelper {
return "ri-lock-password-line";
case "autodate":
return "ri-calendar-check-line";
case "geoPoint":
return "ri-map-pin-2-line";
default:
return "ri-star-s-line";
}
@@ -1774,7 +1776,12 @@ export default class CommonHelper {
const fields = collection.fields || [];
for (const field of fields) {
CommonHelper.pushUnique(result, prefix + field.name);
if (field.type == "geoPoint") {
CommonHelper.pushUnique(result, prefix + field.name + ".lon");
CommonHelper.pushUnique(result, prefix + field.name + ".lat");
} else {
CommonHelper.pushUnique(result, prefix + field.name);
}
}
return result;