Support filtering location points by country/address/postalCode/description

This commit is contained in:
Fabio Manganiello 2025-04-05 22:04:21 +02:00
parent 36846164bd
commit ca666c9ef8
Signed by: blacklight
GPG key ID: D90FBA7F76362774
8 changed files with 559 additions and 138 deletions

View file

@ -48,6 +48,7 @@
<div class="controls">
<div class="form-container" v-if="showControls">
<FilterForm :value="locationQuery"
:countries="countries"
:devices="devices"
:disabled="loading"
:resolution="resolutionMeters"
@ -110,6 +111,7 @@ import { useGeographic } from 'ol/proj';
import type { Optional } from '../models/Types';
import Api from '../mixins/Api.vue';
import ConfirmDialog from '../elements/ConfirmDialog.vue';
import Country from '../models/Country';
import Dates from '../mixins/Dates.vue';
import Feature from 'ol/Feature';
import FilterButton from './filter/ToggleButton.vue';
@ -118,11 +120,13 @@ import FloatingButton from '../elements/FloatingButton.vue';
import GPSPoint from '../models/GPSPoint';
import LocationQuery from '../models/LocationQuery';
import LocationQueryMixin from '../mixins/LocationQuery.vue';
import LocationStats from '../models/LocationStats';
import MapSelectOverlay from './MapSelectOverlay.vue';
import MapView from '../mixins/MapView.vue';
import Paginate from '../mixins/Paginate.vue';
import Points from '../mixins/Points.vue';
import Routes from '../mixins/Routes.vue';
import StatsRequest from '../models/StatsRequest';
import Timeline from './Timeline.vue';
import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration';
import URLQueryHandler from '../mixins/URLQueryHandler.vue';
@ -155,6 +159,7 @@ export default {
data() {
return {
countries: [] as string[],
devices: [] as UserDevice[],
loading: false,
map: null as Optional<Map>,
@ -302,6 +307,21 @@ export default {
await this.processQueryChange(this.locationQuery, oldQuery)
},
async getCountries() {
return (
await this.getStats(
new StatsRequest({
userId: this.$root.user.id,
groupBy: ['country'],
order: 'desc',
})
)
)
.filter((record: LocationStats) => !!record.key.country)
.map((record: LocationStats) => Country.fromCode(record.key.country))
.filter((country: Optional<Country>) => !!country)
},
createMap(): Map {
this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[])
this.routesLayer = this.createRoutesLayer(Object.values(this.mappedPoints) as Point[])
@ -575,6 +595,7 @@ export default {
])
this.map = this.createMap()
this.countries = await this.getCountries()
},
}
</script>
@ -608,6 +629,7 @@ main {
position: absolute;
bottom: 0;
left: 0;
max-height: calc(100% + 4em);
display: flex;
flex-direction: column;
padding: 0.5em;
@ -618,6 +640,7 @@ main {
}
.form-container {
max-height: calc(100% - 8em);
margin-bottom: 0.5em;
animation: unroll 0.25s ease-out;
}

View file

@ -1,97 +1,99 @@
<template>
<form class="filter-view" @submit.prevent.stop="handleSubmit">
<h2>Filter</h2>
<header>
<h2>Filter</h2>
</header>
<div class="date-range-toggle">
<input type="checkbox"
id="date-range-toggle"
name="date-range-toggle"
v-model="enableDateRange"
:disabled="disabled" />
<label for="date-range-toggle">Enable Date Range</label>
</div>
<main>
<div class="date-range-toggle">
<input type="checkbox"
id="date-range-toggle"
name="date-range-toggle"
v-model="enableDateRange"
:disabled="disabled" />&nbsp;
<label for="date-range-toggle">Set Date Range</label>
</div>
<div class="date-selectors" v-if="enableDateRange">
<div class="date-selector">
<label for="start-date">
<font-awesome-icon icon="fas fa-calendar-day" />
Start Date
</label>
<input type="datetime-local"
id="start-date"
name="start-date"
@input="newFilter.startDate = startPlusHours($event, 0)"
@change="newFilter.startDate = startPlusHours($event, 0)"
:value="toLocalString(newFilter.startDate)"
:disabled="disabled"
:max="maxDate" />
<div class="date-selectors" v-if="enableDateRange">
<div class="date-selector">
<label for="start-date">
<font-awesome-icon icon="fas fa-calendar-day" />
Start Date
</label>
<input type="datetime-local"
id="start-date"
name="start-date"
@input="newFilter.startDate = startPlusHours($event, 0)"
@change="newFilter.startDate = startPlusHours($event, 0)"
:value="toLocalString(newFilter.startDate)"
:disabled="disabled"
:max="maxDate" />
<div class="footer">
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
:disabled="disabled || !newFilter.startDate">-1w</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
:disabled="disabled || !newFilter.startDate">-1d</button>
<button type="button"
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
:disabled="disabled || !newFilter.startDate">-1h</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
:disabled="disabled || !newFilter.startDate">Now</button>
<button type="button"
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
:disabled="disabled || !newFilter.startDate">+1h</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
:disabled="disabled || !newFilter.startDate">+1d</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
:disabled="disabled || !newFilter.startDate">+1w</button>
<div class="footer">
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
:disabled="disabled || !newFilter.startDate">-1w</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
:disabled="disabled || !newFilter.startDate">-1d</button>
<button type="button"
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
:disabled="disabled || !newFilter.startDate">-1h</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
:disabled="disabled || !newFilter.startDate">Now</button>
<button type="button"
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
:disabled="disabled || !newFilter.startDate">+1h</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
:disabled="disabled || !newFilter.startDate">+1d</button>
<button type="button"
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
:disabled="disabled || !newFilter.startDate">+1w</button>
</div>
</div>
<div class="date-selector">
<label for="end-date">
<font-awesome-icon icon="fas fa-calendar-day" />
End Date
</label>
<input type="datetime-local"
id="end-date"
name="end-date"
@input="newFilter.endDate = endPlusHours($event, 0)"
@change="newFilter.endDate = endPlusHours($event, 0)"
:value="toLocalString(newFilter.endDate)"
:disabled="disabled"
:max="maxDate" />
<div class="footer">
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
:disabled="disabled || !newFilter.endDate">-1w</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
:disabled="disabled || !newFilter.endDate">-1d</button>
<button type="button"
@click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
:disabled="disabled || !newFilter.endDate">-1h</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(new Date(), 0)"
:disabled="disabled || !newFilter.endDate">Now</button>
<button type="button"
@click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
:disabled="disabled || !newFilter.endDate">+1h</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
:disabled="disabled || !newFilter.endDate">+1d</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
:disabled="disabled || !newFilter.endDate">+1w</button>
</div>
</div>
</div>
<div class="date-selector">
<label for="end-date">
<font-awesome-icon icon="fas fa-calendar-day" />
End Date
</label>
<input type="datetime-local"
id="end-date"
name="end-date"
@input="newFilter.endDate = endPlusHours($event, 0)"
@change="newFilter.endDate = endPlusHours($event, 0)"
:value="toLocalString(newFilter.endDate)"
:disabled="disabled"
:max="maxDate" />
<div class="footer">
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
:disabled="disabled || !newFilter.endDate">-1w</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
:disabled="disabled || !newFilter.endDate">-1d</button>
<button type="button"
@click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
:disabled="disabled || !newFilter.endDate">-1h</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(new Date(), 0)"
:disabled="disabled || !newFilter.endDate">Now</button>
<button type="button"
@click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
:disabled="disabled || !newFilter.endDate">+1h</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
:disabled="disabled || !newFilter.endDate">+1d</button>
<button type="button"
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
:disabled="disabled || !newFilter.endDate">+1w</button>
</div>
</div>
</div>
<div class="form-row">
<div class="container limit-container">
<label for="limit">
<font-awesome-icon icon="fas fa-list-ol" />
@ -120,9 +122,7 @@
<option value="desc">Newest points first</option>
</select>
</div>
</div>
<div class="form-row">
<div class="container resolution-container">
<label for="resolution">
<p class="title">
@ -158,19 +158,105 @@
<option v-for="device in devices" :key="device.id" :value="device.id">{{ device.name }}</option>
</select>
</div>
</div>
<div class="footer">
<div class="container country-container">
<label for="country">
<p class="title">
<font-awesome-icon icon="fas fa-globe" />
Country
</p>
</label>
<Autocomplete
id="country"
name="country"
placeholder="Filter by country"
allow-only-values
v-model="newFilter.country"
:values="autocompleteCountries"
:disabled="disabled"
@input="newFilter.country = $event" />
</div>
<div class="container locality-container">
<label for="locality">
<p class="title">
<font-awesome-icon icon="fas fa-map-marker-alt" />
Locality
</p>
</label>
<input type="text"
id="locality"
name="locality"
placeholder="Filter by locality"
v-model="newFilter.locality"
:disabled="disabled" />
</div>
<div class="container address-container">
<label for="address">
<p class="title">
<font-awesome-icon icon="fas fa-home" />
Address
</p>
</label>
<input type="text"
id="address"
name="address"
placeholder="Filter by address"
v-model="newFilter.address"
:disabled="disabled" />
</div>
<div class="container postal-code-container col-s-12">
<label for="postal-code">
<p class="title">
<font-awesome-icon icon="fas fa-mail-bulk" />
Postal Code
</p>
</label>
<input type="text"
id="postal-code"
name="postal-code"
placeholder="Filter by postal code"
v-model="newFilter.postalCode"
:disabled="disabled" />
</div>
<div class="container description-container">
<label for="description">
<p class="title">
<font-awesome-icon icon="fas fa-comment" />
Description
</p>
</label>
<input type="text"
id="description"
name="description"
placeholder="Filter by description"
v-model="newFilter.description"
:disabled="disabled" />
</div>
</main>
<footer>
<button type="submit" :disabled="disabled || !changed">
<font-awesome-icon icon="fas fa-check" />&nbsp;Apply
</button>
</div>
</footer>
</form>
</template>
<script lang="ts">
import _ from 'lodash'
import Autocomplete from '../../elements/Autocomplete.vue'
import AutocompleteValue from '../../models/AutocompleteValue'
import Country from '../../models/Country'
import LocationQuery from '../../models/LocationQuery'
import LocationQueryMixin from '../../mixins/LocationQuery.vue'
import UserDevice from '../../models/UserDevice'
@ -182,8 +268,16 @@ export default {
'set-resolution',
],
components: {
Autocomplete,
},
props: {
value: LocationQuery,
countries: {
type: Array as () => Country[],
default: () => [],
},
devices: {
type: Array as () => UserDevice[],
default: () => [],
@ -199,6 +293,13 @@ export default {
},
computed: {
autocompleteCountries(): AutocompleteValue[] {
return this.countries.map((country: Country) => ({
value: country.code,
label: `${country.flag} ${country.name}`,
data: country,
}))
},
maxDate() {
return this.toLocalString(this.endPlusHours(new Date(), 0))
}
@ -340,26 +441,95 @@ export default {
<style lang="scss" scoped>
@use "@/styles/common.scss" as *;
$header-height: 2.5em;
$footer-height: 3.5em;
.filter-view {
height: 100%;
background: var(--color-background);
display: flex;
flex-direction: column;
align-items: center;
padding: 1em 0;
justify-content: center;
padding: 1em;
border: 1px solid var(--color-border);
border-radius: 0.5em;
margin-bottom: 0.25em;
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
@include media(mobile) {
@include until(desktop) {
width: calc(100vw - 2em);
}
@include media(tablet) {
@include from(desktop) {
width: 50em;
}
header {
width: 100%;
min-width: 45em;
height: $header-height;
display: flex;
justify-content: center;
}
footer {
width: 100%;
height: $footer-height;
display: flex;
justify-content: center;
button {
height: 2.5em;
}
}
main {
height: calc(100% - $header-height - $footer-height - 5em);
max-height: 25em;
overflow-y: auto;
overflow-x: hidden;
display: flex;
padding: 1em;
@include until(tablet) {
flex-direction: column;
flex-wrap: nowrap;
}
@include from(tablet) {
flex-direction: row;
flex-wrap: wrap;
}
.container {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5em 1em;
flex-direction: column;
@include until(tablet) {
width: 100%;
}
@include from(tablet) {
width: 50%;
}
label {
margin-bottom: 0.25em;
@include until(tablet) {
margin-top: 0.75em;
}
}
input {
width: 100%;
@include until(tablet) {
margin-bottom: 0.5em;
}
}
}
}
.date-selectors {
@ -403,40 +573,16 @@ export default {
}
.date-range-toggle {
width: 100%;
display: flex;
align-items: center;
margin: 0.5em 0 -0.5em 0;
justify-content: center;
input {
margin-right: 0.25em;
}
}
.form-row {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 0.5em;
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 1em;
}
label {
margin-bottom: 0.25em;
}
input {
width: 100%;
}
}
.limit-container {
display: flex;
flex-direction: column;
@ -444,11 +590,6 @@ export default {
}
.resolution-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5em;
label {
margin-bottom: 0.25em;
display: flex;

View file

@ -0,0 +1,179 @@
<template>
<div class="autocomplete">
<div class="input">
<input
type="text"
:id="id"
:name="name"
:placeholder="placeholder"
v-model="newValue"
@focus="onFocus"
@blur="onBlur"
ref="input"
/>
</div>
<div class="values" v-if="showValues" @click.stop>
<ul>
<li v-for="value in filteredValues"
:key="value.value"
@click.stop="onItemClick(value)">
{{ value.label }}
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import AutocompleteValue from '../models/AutocompleteValue';
export default {
emits: ['input'],
props: {
id: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
value: {
type: String,
default: '',
},
values: {
type: Array as () => AutocompleteValue[],
default: () => [],
},
allowOnlyValues: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: 'Search...',
},
},
data() {
return {
newValue: '' + this.value,
showValues: false,
};
},
computed: {
filteredValues() {
if (!this.newValue?.length) {
return this.values;
}
let matches = this.values.filter((value: AutocompleteValue) =>
value.value.toLowerCase() === this.newValue.toLowerCase()
) as AutocompleteValue[];
if (!matches.length) {
matches = this.values.filter((value: AutocompleteValue) =>
value.label.toLowerCase().includes(this.newValue.toLowerCase())
) as AutocompleteValue[];
}
return matches;
},
indexedValues() {
return Object.fromEntries(
this.values.map((value: AutocompleteValue) => [
value.value,
value,
])
);
},
},
methods: {
onFocus() {
this.showValues = true;
},
onBlur() {
setTimeout(() => {
this.showValues = false;
this.emitInput();
}, 500);
},
onItemClick(value: AutocompleteValue) {
this.newValue = value.value;
this.showValues = false;
this.emitInput();
},
emitInput() {
this.$emit(
'input',
this.allowOnlyValues
? this.indexedValues[this.newValue]?.value
: this.newValue
)
}
},
watch: {
value(newValue: string) {
this.newValue = newValue;
},
newValue() {
this.emitInput();
},
},
}
</script>
<style lang="scss" scoped>
.autocomplete {
width: 100%;
position: relative;
.input {
input {
width: 100%;
}
}
.values {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 20em;
overflow: auto;
background-color: var(--color-background);
z-index: 10;
box-shadow: 0 0 0.5rem 0 rgba(0, 0, 0, 0.5);
ul {
list-style-type: none;
margin: 0;
padding: 0;
li {
padding: 0.8em;
cursor: pointer;
&:hover {
background-color: var(--color-accent-bg);
}
}
}
}
}
</style>

View file

@ -14,18 +14,22 @@ export default {
newValue?: LocationQuery,
oldValue?: LocationQuery,
}): boolean {
return !_.isEqual(
{
...(oldValue || {}),
startDate: this.normalizeDate(oldValue?.startDate),
endDate: this.normalizeDate(oldValue?.endDate),
},
{
...(newValue || {}),
startDate: this.normalizeDate(newValue?.startDate),
endDate: this.normalizeDate(newValue?.endDate),
}
const values = [oldValue, newValue].map((value) =>
Object.entries(value || {}).reduce((acc, [key, val]) => {
// Replace all undefined values with null to avoid the comparison from breaking
// when an attribute is not set and it's undefined on one side and null on the other
acc[key] = val === undefined ? null : val
// Normalize dates to avoid issues with different date formats
if (key === 'startDate' || key === 'endDate') {
acc[key] = this.normalizeDate(val)
}
return acc
}, {} as Record<string, any>)
)
return !_.isEqual(values[0], values[1])
},
}
}

View file

@ -0,0 +1,21 @@
class AutocompleteValue {
value: string;
label: string;
data?: any | null = undefined;
constructor(record: {
value: string;
label: string;
data?: any | null;
}) {
this.value = record.value;
this.label = record.label;
this.data = record.data;
}
toString(): string {
return this.label;
}
}
export default AutocompleteValue;

View file

@ -0,0 +1,36 @@
import type { TCountryCode } from 'countries-list';
import { getCountryData, getEmojiFlag } from 'countries-list';
class Country {
name: string;
code: string;
continent: string;
flag: string;
constructor(data: {
name: string;
code: string;
continent: string;
flag: string;
}) {
this.name = data.name;
this.code = data.code;
this.continent = data.continent;
this.flag = data.flag;
}
public static fromCode(code: string | TCountryCode): Country | null {
const cc = code.toUpperCase() as TCountryCode;
const countryData = getCountryData(cc);
if (!countryData) return null;
return new Country({
name: countryData.name,
code: code,
continent: countryData.continent,
flag: getEmojiFlag(cc),
});
}
}
export default Country;

View file

@ -16,6 +16,23 @@ $breakpoints: (
}
}
// Generate col-<breakpoint>-<number> classes
$screen-size-to-breakpoint: (
s:mobile,
m:tablet,
l:desktop
);
@for $i from 1 through 12 {
@each $screen-size, $breakpoint in $screen-size-to-breakpoint {
.col-#{$screen-size}-#{$i} {
@media (min-width: map.get($breakpoints, $breakpoint)) {
width: (100% / 12) * $i;
}
}
}
}
.hidden {
display: none !important;
}

View file

@ -58,7 +58,7 @@ class LocationRequest {
this.initNumber('maxLatitude', req, parseFloat);
this.initNumber('minLongitude', req, parseFloat);
this.initNumber('maxLongitude', req, parseFloat);
this.country = req.country;
this.country = req.country?.toLowerCase();
this.locality = req.locality;
this.postalCode = req.postalCode;
this.description = req.description;