mirror of
https://github.com/bellingcat/osm-search.git
synced 2026-06-13 05:58:32 +03:00
Add API
This commit is contained in:
164
frontend/src/components/FeatureSelector.vue
Normal file
164
frontend/src/components/FeatureSelector.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-card
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
:color="accepting ? '#ddd' : '#fff'"
|
||||
style="min-height: 100%"
|
||||
>
|
||||
<v-card-title>Selected features</v-card-title>
|
||||
<v-card-text>
|
||||
<v-col>
|
||||
<v-card
|
||||
v-for="(query, i) in $store.state.selected"
|
||||
:key="query.name + query.type"
|
||||
close
|
||||
style="margin-bottom: 1em"
|
||||
>
|
||||
<v-card-title
|
||||
:color="
|
||||
query.type == 'point'
|
||||
? '#8BC34A'
|
||||
: query.type == 'line'
|
||||
? '#00BCD4'
|
||||
: '#FFC107'
|
||||
"
|
||||
>
|
||||
{{ query.name }}
|
||||
<span class="type">({{ query.type }})</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<span class="code">
|
||||
{{ query.filter }}
|
||||
</span>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn color="red" text @click="remove(i)"> Remove </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-card>
|
||||
<v-card-title>Feature presets</v-card-title>
|
||||
<v-card-text>
|
||||
<v-chip
|
||||
v-for="query in queries"
|
||||
:key="query.name + query.type"
|
||||
:color="
|
||||
query.type == 'point'
|
||||
? '#8BC34A'
|
||||
: query.type == 'line'
|
||||
? '#00BCD4'
|
||||
: '#FFC107'
|
||||
"
|
||||
draggable
|
||||
@dragstart="startDrag($event, query)"
|
||||
style="margin: 0.25em"
|
||||
@click="addFeature(query)"
|
||||
>{{ query.name }}</v-chip
|
||||
>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card style="margin-top: 1em">
|
||||
<v-card-title>Custom feature</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="3"
|
||||
><v-select
|
||||
label="Feature type"
|
||||
:items="queryTypes"
|
||||
v-model="selectedQueryType"
|
||||
></v-select> </v-col
|
||||
><v-col>
|
||||
<v-text-field
|
||||
class="code"
|
||||
label="Filter statement"
|
||||
v-model="customFilter"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn color="primary" text @click="addCustom">Add</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import queries from "./queries.js";
|
||||
|
||||
export default {
|
||||
name: "FeatureSelector",
|
||||
data() {
|
||||
return {
|
||||
queries,
|
||||
queryTypes: ["point", "line", "polygon"],
|
||||
selectedQueryType: "point",
|
||||
customFilter: "",
|
||||
accepting: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onDrop(e) {
|
||||
this.accepting = false;
|
||||
let data = JSON.parse(e.dataTransfer.getData("object"));
|
||||
this.$store.commit("updateSelected", [
|
||||
...this.$store.state.selected,
|
||||
data,
|
||||
]);
|
||||
},
|
||||
addFeature(f) {
|
||||
this.$store.commit("updateSelected", [...this.$store.state.selected, f]);
|
||||
},
|
||||
onDragOver(e) {
|
||||
this.accepting = true;
|
||||
e.preventDefault();
|
||||
},
|
||||
onDragLeave() {
|
||||
this.accepting = false;
|
||||
},
|
||||
startDrag(e, item) {
|
||||
e.dataTransfer.setData("object", JSON.stringify(item));
|
||||
},
|
||||
remove(index) {
|
||||
let value = this.$store.state.selected;
|
||||
let newValue = [
|
||||
...value.slice(0, index),
|
||||
...value.slice(index + 1, value.length),
|
||||
];
|
||||
console.log(index, value, newValue);
|
||||
this.$store.commit("updateSelected", newValue);
|
||||
},
|
||||
addCustom() {
|
||||
this.$store.commit("updateSelected", [
|
||||
...this.$store.state.selected,
|
||||
{
|
||||
name: "Custom filter",
|
||||
type: this.selectedQueryType,
|
||||
filter: this.customFilter,
|
||||
},
|
||||
]);
|
||||
|
||||
this.customFilter = "";
|
||||
this.selectedQueryType = "point";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.code {
|
||||
font-family: Consolas, "Roboto Mono", Courier, monospace;
|
||||
}
|
||||
|
||||
.type {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/components/GoogleLogin.vue
Executable file
77
frontend/src/components/GoogleLogin.vue
Executable file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<v-toolbar-items class="my-auto align-center">
|
||||
<div
|
||||
v-if="!$store.state.user"
|
||||
id="google-login-btn"
|
||||
v-google-identity-login-btn="{ clientId }"
|
||||
aria-label="Sign in with Google"
|
||||
key="login"
|
||||
></div>
|
||||
<div style="display: flex" key="logout" v-else>
|
||||
<v-card
|
||||
class="login-button px-2 py-2 my-auto"
|
||||
outllined
|
||||
light
|
||||
color="white"
|
||||
style="margin-right: 1em"
|
||||
>
|
||||
<v-avatar size="24">
|
||||
<img :src="$store.state.user.picture" alt="Google profile picture" />
|
||||
</v-avatar>
|
||||
Signed in
|
||||
</v-card>
|
||||
<v-btn class="px-2 py-2 my-auto" @click="$store.commit('signOut')">
|
||||
Sign out
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-toolbar-items>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OneTap from "@/directives/OneTap.js";
|
||||
|
||||
export default {
|
||||
name: "GoogleLogin",
|
||||
|
||||
directives: {
|
||||
OneTap,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
clientId:
|
||||
"919009657823-74o4l4qjo8ugebg9evb6are67q0ifd6j.apps.googleusercontent.com",
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onGoogleAuthSuccess(jwtCredentials) {
|
||||
const profileData = JSON.parse(atob(jwtCredentials.split(".")[1]));
|
||||
this.$store.commit("setUser", {
|
||||
token: jwtCredentials,
|
||||
user: profileData,
|
||||
});
|
||||
this.$store.commit("setError", false);
|
||||
|
||||
if (
|
||||
!this.$store.state.channelsLoading &&
|
||||
this.$store.state.channels.length == 0
|
||||
) {
|
||||
this.$store.dispatch("loadInitialData");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "GoogleSans";
|
||||
src: url("../assets/fonts/GoogleSans-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.login-button {
|
||||
font-family: "GoogleSans";
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
133
frontend/src/components/HelloWorld.vue
Normal file
133
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row class="text-center">
|
||||
<v-col cols="12">
|
||||
<v-img
|
||||
:src="require('../assets/logo.svg')"
|
||||
class="my-3"
|
||||
contain
|
||||
height="200"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col class="mb-4">
|
||||
<h1 class="display-2 font-weight-bold mb-3">Welcome to Vuetify</h1>
|
||||
|
||||
<p class="subheading font-weight-regular">
|
||||
For help and collaboration with other Vuetify developers,
|
||||
<br />please join our online
|
||||
<a href="https://community.vuetifyjs.com" target="_blank"
|
||||
>Discord Community</a
|
||||
>
|
||||
</p>
|
||||
</v-col>
|
||||
|
||||
<v-col class="mb-5" cols="12">
|
||||
<h2 class="headline font-weight-bold mb-3">What's next?</h2>
|
||||
|
||||
<v-row justify="center">
|
||||
<a
|
||||
v-for="(next, i) in whatsNext"
|
||||
:key="i"
|
||||
:href="next.href"
|
||||
class="subheading mx-3"
|
||||
target="_blank"
|
||||
>
|
||||
{{ next.text }}
|
||||
</a>
|
||||
</v-row>
|
||||
</v-col>
|
||||
|
||||
<v-col class="mb-5" cols="12">
|
||||
<h2 class="headline font-weight-bold mb-3">Important Links</h2>
|
||||
|
||||
<v-row justify="center">
|
||||
<a
|
||||
v-for="(link, i) in importantLinks"
|
||||
:key="i"
|
||||
:href="link.href"
|
||||
class="subheading mx-3"
|
||||
target="_blank"
|
||||
>
|
||||
{{ link.text }}
|
||||
</a>
|
||||
</v-row>
|
||||
</v-col>
|
||||
|
||||
<v-col class="mb-5" cols="12">
|
||||
<h2 class="headline font-weight-bold mb-3">Ecosystem</h2>
|
||||
|
||||
<v-row justify="center">
|
||||
<a
|
||||
v-for="(eco, i) in ecosystem"
|
||||
:key="i"
|
||||
:href="eco.href"
|
||||
class="subheading mx-3"
|
||||
target="_blank"
|
||||
>
|
||||
{{ eco.text }}
|
||||
</a>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "HelloWorld",
|
||||
|
||||
data: () => ({
|
||||
ecosystem: [
|
||||
{
|
||||
text: "vuetify-loader",
|
||||
href: "https://github.com/vuetifyjs/vuetify-loader",
|
||||
},
|
||||
{
|
||||
text: "github",
|
||||
href: "https://github.com/vuetifyjs/vuetify",
|
||||
},
|
||||
{
|
||||
text: "awesome-vuetify",
|
||||
href: "https://github.com/vuetifyjs/awesome-vuetify",
|
||||
},
|
||||
],
|
||||
importantLinks: [
|
||||
{
|
||||
text: "Documentation",
|
||||
href: "https://vuetifyjs.com",
|
||||
},
|
||||
{
|
||||
text: "Chat",
|
||||
href: "https://community.vuetifyjs.com",
|
||||
},
|
||||
{
|
||||
text: "Made with Vuetify",
|
||||
href: "https://madewithvuejs.com/vuetify",
|
||||
},
|
||||
{
|
||||
text: "Twitter",
|
||||
href: "https://twitter.com/vuetifyjs",
|
||||
},
|
||||
{
|
||||
text: "Articles",
|
||||
href: "https://medium.com/vuetify",
|
||||
},
|
||||
],
|
||||
whatsNext: [
|
||||
{
|
||||
text: "Explore components",
|
||||
href: "https://vuetifyjs.com/components/api-explorer",
|
||||
},
|
||||
{
|
||||
text: "Select a layout",
|
||||
href: "https://vuetifyjs.com/getting-started/pre-made-layouts",
|
||||
},
|
||||
{
|
||||
text: "Frequently Asked Questions",
|
||||
href: "https://vuetifyjs.com/getting-started/frequently-asked-questions",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
</script>
|
||||
201
frontend/src/components/SearchControls.vue
Normal file
201
frontend/src/components/SearchControls.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row style="padding: 0.75em">
|
||||
<v-card style="width: 100%">
|
||||
<v-card-title>Getting started</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
With the OpenStreetMap search tool, a researcher can find
|
||||
geolocation leads by searching for specific objects on
|
||||
OpenStreetMap.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To begin, drag a feature type from the presets list to the "Selected
|
||||
features" list. Adding multiple features will find only locations
|
||||
where those features are nearby each other. Set the maximum distance
|
||||
slider to adjust how far apart the features can be. Adjust the map
|
||||
to contain the area that you want to search, and press the search
|
||||
button. Some queries may take several minutes to run. To increase
|
||||
the speed, zoom in on the map to select a smaller area. Results can
|
||||
be browsed directly, opened in Google Maps by clicking the lat/lng,
|
||||
or downloaded as a CSV or KML file.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
OpenStreetMap is very detailed but accuracy and completeness varies
|
||||
significantly around the world. This tool can be used to find
|
||||
possible leads, but it should not be considered exhaustive or used
|
||||
to exclude areas of interest.
|
||||
<strong
|
||||
>Want to search for a type of feature that's not included on the
|
||||
list?</strong
|
||||
>
|
||||
Contact logan@bellingcat.com.
|
||||
</p>
|
||||
</v-card-text></v-card
|
||||
>
|
||||
</v-row>
|
||||
<feature-selector />
|
||||
<v-alert
|
||||
type="error"
|
||||
style="padding: 0.75em; margin-top: 1em"
|
||||
v-if="$store.state.selected.length < 1"
|
||||
>
|
||||
Select at least one feature to begin a search.
|
||||
</v-alert>
|
||||
<v-row style="padding: 0.75em">
|
||||
<v-card style="width: 100%">
|
||||
<v-card-title>Maximum distance between features</v-card-title>
|
||||
<v-card-text>
|
||||
<v-slider
|
||||
v-model="range"
|
||||
thumb-label="always"
|
||||
:thumb-size="36"
|
||||
:max="500"
|
||||
style="margin-bottom: -1em; margin-top: 1em"
|
||||
label="Longer distance will take longer to search"
|
||||
>
|
||||
<template v-slot:thumb-label="{ value }"> {{ value }}m </template>
|
||||
</v-slider>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-row>
|
||||
<v-row style="padding: 0.75em">
|
||||
<v-card style="width: 100%">
|
||||
<v-card-title>Search area</v-card-title>
|
||||
<l-map
|
||||
:zoom.sync="zoom"
|
||||
:center.sync="center"
|
||||
style="width: 100%; height: 600px"
|
||||
ref="map"
|
||||
>
|
||||
<l-tile-layer :url="url" />
|
||||
<l-circle-marker
|
||||
v-for="(result, i) in $store.state.searchResults"
|
||||
:lat-lng="[result.lat, result.lng]"
|
||||
:key="'marker' + i"
|
||||
:radius="4"
|
||||
:color="
|
||||
i == $store.state.hovered
|
||||
? '#673AB7'
|
||||
: i == $store.state.selectedResult
|
||||
? '#E91E63'
|
||||
: '#2196F3'
|
||||
"
|
||||
@mouseover="$store.commit('setHoveredResult', i)"
|
||||
@mouseleave="$store.commit('setHoveredResult', null)"
|
||||
@click="mapClick(i)"
|
||||
/>
|
||||
<l-rectangle
|
||||
v-if="$store.state.bbox.length > 0"
|
||||
:bounds="$store.state.bbox"
|
||||
:fill="false"
|
||||
color="blue"
|
||||
:weight="3"
|
||||
></l-rectangle>
|
||||
</l-map>
|
||||
</v-card>
|
||||
</v-row>
|
||||
<v-alert
|
||||
type="error"
|
||||
style="padding: 0.75em; margin-top: 1em"
|
||||
v-if="zoom < 6"
|
||||
>
|
||||
Your search area is too large. Zoom in to reduce the search area.
|
||||
</v-alert>
|
||||
<v-alert
|
||||
type="warning"
|
||||
style="padding: 0.75em; margin-top: 1em"
|
||||
v-else-if="zoom < 8"
|
||||
>
|
||||
Your search area is very large. You can still run it, but the search may
|
||||
fail or take a long time to execute. Zoom in to reduce the search area.
|
||||
</v-alert>
|
||||
<v-row style="padding: 0.75em">
|
||||
<v-btn @click="search">Search</v-btn>
|
||||
</v-row>
|
||||
<v-alert
|
||||
type="error"
|
||||
style="padding: 0.75em; margin-top: 1em"
|
||||
v-if="$store.state.error"
|
||||
>
|
||||
{{ $store.state.error }}
|
||||
</v-alert>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { LMap, LTileLayer, LCircleMarker, LRectangle } from "vue2-leaflet";
|
||||
import FeatureSelector from "./FeatureSelector.vue";
|
||||
|
||||
export default {
|
||||
name: "SearchControls",
|
||||
components: {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
LCircleMarker,
|
||||
LRectangle,
|
||||
FeatureSelector,
|
||||
},
|
||||
computed: {
|
||||
range: {
|
||||
get() {
|
||||
return this.$store.state.range;
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit("setRange", val);
|
||||
},
|
||||
},
|
||||
center: {
|
||||
get() {
|
||||
return this.$store.state.mapCenter;
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit("setCenter", val);
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
get() {
|
||||
return this.$store.state.mapZoom;
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit("setZoom", val);
|
||||
},
|
||||
},
|
||||
url() {
|
||||
if (this.$store.state.mode == "google") {
|
||||
return "https://maps.googleapis.com/maps/vt?pb=!1m5!1m4!1i{z}!2i{x}!3i{y}!4i256!2m3!1e0!2sm!3i70350780!3m12!2sen-US!3sUS!5e18!12m4!1e68!2m2!1sset!2sRoadmap!12m3!1e37!2m1!1ssmartmaps!4e0!23i1379903&key=AIzaSyAo0g0nZh5aOEhMW2S876KMjJ8OqaN-VwQ";
|
||||
} else if (this.$store.state.mode == "satellite") {
|
||||
return "https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/512/{z}/{x}/{y}{r}?access_token=pk.eyJ1IjoiYmVsbGluZ2NhdC1tYXBib3giLCJhIjoiY2w4c201OGZsMHdkOTNwbWhkb3I4dGE2cCJ9.GFxMJQJ-dV7VRBAcTTHOzg";
|
||||
} else {
|
||||
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
search() {
|
||||
if (this.zoom < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bounds = this.$refs.map.mapObject.getBounds();
|
||||
|
||||
let bbox = [
|
||||
[bounds._southWest.lat, bounds._southWest.lng],
|
||||
[bounds._northEast.lat, bounds._northEast.lng],
|
||||
];
|
||||
|
||||
this.$store.commit("setBbox", bbox);
|
||||
this.$store.dispatch("search");
|
||||
},
|
||||
mapClick(i) {
|
||||
this.$store.commit("setSelectedResult", i);
|
||||
console.log("scrolling?");
|
||||
document
|
||||
.getElementById("result" + i)
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
126
frontend/src/components/SearchResult.vue
Normal file
126
frontend/src/components/SearchResult.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<v-col>
|
||||
<v-card
|
||||
:id="'result' + index"
|
||||
class="result"
|
||||
:color="hovered ? '#D1C4E9' : selected ? '#F48FB1' : '#FFFFFF'"
|
||||
@mouseover="$store.commit('setHoveredResult', index)"
|
||||
@mouseleave="$store.commit('setHoveredResult', null)"
|
||||
@click="clicked"
|
||||
>
|
||||
<v-card-title>{{ resultIndex + 1 }} </v-card-title>
|
||||
<v-card-subtitle>
|
||||
{{ result.name }}
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<div class="map">
|
||||
<l-map
|
||||
:zoom="17"
|
||||
:center="[result.lat, result.lng]"
|
||||
:options="{ zoomControl: false }"
|
||||
style="width: 180px; height: 100px"
|
||||
>
|
||||
<l-tile-layer :url="url" />
|
||||
</l-map>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<!-- <a
|
||||
class="outlink"
|
||||
target="_blank"
|
||||
:href="`https://www.google.com/maps/search/?api=1&query=${result.lat},${result.lng}`"
|
||||
> -->
|
||||
<v-btn
|
||||
:href="`https://www.google.com/maps/search/?api=1&query=${result.lat},${result.lng}`"
|
||||
text
|
||||
target="_blank"
|
||||
>({{ result.lat.toFixed(5) }}, {{ result.lng.toFixed(5) }})</v-btn
|
||||
>
|
||||
<!-- </a> -->
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { LMap, LTileLayer } from "vue2-leaflet";
|
||||
|
||||
export default {
|
||||
name: "SearchResult",
|
||||
components: {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
},
|
||||
props: {
|
||||
result: Object,
|
||||
resultIndex: Number,
|
||||
mode: String,
|
||||
index: Number,
|
||||
},
|
||||
computed: {
|
||||
url() {
|
||||
if (this.$store.state.mode == "google") {
|
||||
return "https://maps.googleapis.com/maps/vt?pb=!1m5!1m4!1i{z}!2i{x}!3i{y}!4i256!2m3!1e0!2sm!3i70350780!3m12!2sen-US!3sUS!5e18!12m4!1e68!2m2!1sset!2sRoadmap!12m3!1e37!2m1!1ssmartmaps!4e0!23i1379903&key=AIzaSyAo0g0nZh5aOEhMW2S876KMjJ8OqaN-VwQ";
|
||||
} else if (this.$store.state.mode == "satellite") {
|
||||
return "https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/512/{z}/{x}/{y}{r}?access_token=pk.eyJ1IjoiYmVsbGluZ2NhdC1tYXBib3giLCJhIjoiY2w4c201OGZsMHdkOTNwbWhkb3I4dGE2cCJ9.GFxMJQJ-dV7VRBAcTTHOzg";
|
||||
} else {
|
||||
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
}
|
||||
},
|
||||
hovered() {
|
||||
return this.index == this.$store.state.hovered;
|
||||
},
|
||||
selected() {
|
||||
return this.index == this.$store.state.selectedResult;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
clicked() {
|
||||
this.$store.commit("setSelectedResult", this.index);
|
||||
this.$store.commit("setCenter", [this.result.lat, this.result.lng]);
|
||||
this.$store.commit("setZoom", 13);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.map {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.result .leaflet-control-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result:hover {
|
||||
background-color: #d1c4e9;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.index {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.result .v-card__text {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
/* color: black !important; */
|
||||
}
|
||||
|
||||
.outlink:hover {
|
||||
/* background-color: #ddd; */
|
||||
}
|
||||
|
||||
.result .v-btn__content {
|
||||
user-select: all;
|
||||
}
|
||||
</style>
|
||||
119
frontend/src/components/SearchResults.vue
Normal file
119
frontend/src/components/SearchResults.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card :loading="$store.state.loading">
|
||||
<v-card-title>
|
||||
{{
|
||||
$store.state.searchResults.length == 100
|
||||
? "100 results of many"
|
||||
: $store.state.searchResults.length + " total results"
|
||||
}}
|
||||
<span class="timing">{{
|
||||
"in " + ($store.state.responseTime / 1000).toFixed(2) + " seconds"
|
||||
}}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-radio-group v-model="mode" row>
|
||||
<v-radio label="OSM" value="osm" />
|
||||
<v-radio label="Google" value="google" />
|
||||
<v-radio label="Satellite" value="satellite" />
|
||||
</v-radio-group>
|
||||
<div class="results">
|
||||
<v-row>
|
||||
<SearchResult
|
||||
v-for="(result, i) in $store.state.searchResults"
|
||||
:key="'result' + i"
|
||||
:result="result"
|
||||
:resultIndex="i"
|
||||
:index="i"
|
||||
/>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn text @click="csv">Export as CSV</v-btn>
|
||||
<v-btn text @click="kml">Export as KML</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SearchResult from "./SearchResult.vue";
|
||||
import tokml from "tokml";
|
||||
import { saveAs } from "file-saver";
|
||||
import { ExportToCsv } from "export-to-csv";
|
||||
|
||||
export default {
|
||||
name: "SearchResults",
|
||||
components: {
|
||||
SearchResult,
|
||||
},
|
||||
computed: {
|
||||
mode: {
|
||||
get() {
|
||||
return this.$store.state.mode;
|
||||
},
|
||||
set(mode) {
|
||||
this.$store.commit("setMode", mode);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
kml() {
|
||||
let features = this.$store.state.searchResults.map((f) => ({
|
||||
type: "Feature",
|
||||
properties: { name: f.name },
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [f.lng, f.lat],
|
||||
},
|
||||
}));
|
||||
|
||||
let geojson = { type: "FeatureCollection", features };
|
||||
let kml = tokml(geojson);
|
||||
|
||||
saveAs(
|
||||
new Blob([kml], { type: "text/plain;charset=utf-8" }),
|
||||
"osm-search.kml"
|
||||
);
|
||||
},
|
||||
csv() {
|
||||
const options = {
|
||||
fieldSeparator: ",",
|
||||
quoteStrings: '"',
|
||||
decimalSeparator: ".",
|
||||
showLabels: true,
|
||||
showTitle: false,
|
||||
useTextFile: false,
|
||||
useBom: true,
|
||||
useKeysAsHeaders: true,
|
||||
filename: "osm-search",
|
||||
};
|
||||
|
||||
const csvExporter = new ExportToCsv(options);
|
||||
|
||||
csvExporter.generateCsv(
|
||||
this.$store.state.searchResults.map((f) => ({
|
||||
name: f.name,
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
}))
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timing {
|
||||
font-size: 80%;
|
||||
color: #444;
|
||||
margin-left: 1em;
|
||||
margin-bottom: -6px;
|
||||
}
|
||||
</style>
|
||||
178
frontend/src/components/queries.js
Normal file
178
frontend/src/components/queries.js
Normal file
@@ -0,0 +1,178 @@
|
||||
export default [
|
||||
{
|
||||
name: "Power pylon",
|
||||
type: "point",
|
||||
filter: "power = 'tower' OR power = 'pole'",
|
||||
},
|
||||
{
|
||||
name: "Public transport stop",
|
||||
type: "point",
|
||||
filter: "(public_transport IS NOT null OR highway='bus_stop')",
|
||||
},
|
||||
{
|
||||
name: "Church",
|
||||
type: "point",
|
||||
filter: "amenity = 'place_of_worship'",
|
||||
},
|
||||
{
|
||||
name: "Hospital",
|
||||
type: "point",
|
||||
filter: "amenity = 'hospital'",
|
||||
},
|
||||
{
|
||||
name: "Military",
|
||||
type: "point",
|
||||
filter: "military IS NOT null OR landuse = 'military'",
|
||||
},
|
||||
{
|
||||
name: "Restaurant",
|
||||
type: "point",
|
||||
filter:
|
||||
"amenity = 'restaurant' OR amenity = 'cafe' OR amenity = 'pub' OR amenity = 'fast_food'",
|
||||
},
|
||||
{
|
||||
name: "Waterway",
|
||||
type: "line",
|
||||
filter: "waterway IS NOT null",
|
||||
},
|
||||
{
|
||||
name: "Road",
|
||||
type: "line",
|
||||
filter: "highway IS NOT null",
|
||||
},
|
||||
{
|
||||
name: "Railroad",
|
||||
type: "line",
|
||||
filter: "railway IS NOT null",
|
||||
},
|
||||
{
|
||||
name: "Bridge",
|
||||
type: "line",
|
||||
filter: "bridge IS NOT null",
|
||||
},
|
||||
{
|
||||
name: "Road (motorway)",
|
||||
type: "line",
|
||||
filter: "highway = 'motorway' OR highway = 'motorway_link'",
|
||||
},
|
||||
{
|
||||
name: "Road (primary)",
|
||||
type: "line",
|
||||
filter: "highway = 'primary' OR highway = 'primary_link'",
|
||||
},
|
||||
{
|
||||
name: "Road (secondary)",
|
||||
type: "line",
|
||||
filter: "highway = 'secondary' OR highway = 'secondary_link",
|
||||
},
|
||||
{
|
||||
name: "Road (residential)",
|
||||
type: "line",
|
||||
filter: "highway = 'residential'",
|
||||
},
|
||||
{
|
||||
name: "Unpaved road",
|
||||
type: "line",
|
||||
filter: "surface = 'unpaved'",
|
||||
},
|
||||
{
|
||||
name: "1-lane road",
|
||||
type: "line",
|
||||
filter: "tags->'lanes' = '1'",
|
||||
},
|
||||
{
|
||||
name: "2-lane road",
|
||||
type: "line",
|
||||
filter: "tags->'lanes' = '2'",
|
||||
},
|
||||
{
|
||||
name: "3-lane road",
|
||||
type: "line",
|
||||
filter: "tags->'lanes' = '3'",
|
||||
},
|
||||
{
|
||||
name: "4-lane road",
|
||||
type: "line",
|
||||
filter: "tags->'lanes' = '4'",
|
||||
},
|
||||
{
|
||||
name: "5-lane road",
|
||||
type: "line",
|
||||
filter: "tags->'lanes' = '5'",
|
||||
},
|
||||
{
|
||||
name: "6-lane road",
|
||||
type: "line",
|
||||
filter: "tags->'lanes' = '6'",
|
||||
},
|
||||
{
|
||||
name: "Cliff",
|
||||
type: "line",
|
||||
filter: "planet_osm_line.natural = 'cliff'",
|
||||
},
|
||||
{
|
||||
name: "Park",
|
||||
type: "polygon",
|
||||
filter: "leisure = 'park'",
|
||||
},
|
||||
{
|
||||
name: "Industrial area",
|
||||
type: "polygon",
|
||||
filter: "landuse = 'industrial'",
|
||||
},
|
||||
{
|
||||
name: "Body of water",
|
||||
type: "polygon",
|
||||
filter: "water IS NOT null",
|
||||
},
|
||||
{
|
||||
name: "Forest",
|
||||
type: "polygon",
|
||||
filter: "landuse = 'forest' OR planet_osm_polygon.natural = 'forest'",
|
||||
},
|
||||
{
|
||||
name: "Farmland",
|
||||
type: "polygon",
|
||||
filter: "landuse = 'farmland'",
|
||||
},
|
||||
{
|
||||
name: "Building",
|
||||
type: "polygon",
|
||||
filter: "building IS NOT null",
|
||||
},
|
||||
{
|
||||
name: "Building (1 story)",
|
||||
type: "polygon",
|
||||
filter: "tags->'building:levels' = '1'",
|
||||
},
|
||||
{
|
||||
name: "Building (2 story)",
|
||||
type: "polygon",
|
||||
filter: "tags->'building:levels' = '2'",
|
||||
},
|
||||
{
|
||||
name: "Building (3 story)",
|
||||
type: "polygon",
|
||||
filter: "tags->'building:levels' = '3'",
|
||||
},
|
||||
{
|
||||
name: "Building (4 story)",
|
||||
type: "polygon",
|
||||
filter: "tags->'building:levels' = '4'",
|
||||
},
|
||||
{
|
||||
name: "Building (5+ stories)",
|
||||
type: "polygon",
|
||||
filter: "(tags->'building:levels')::integer >= 5",
|
||||
},
|
||||
{
|
||||
name: "Beach",
|
||||
type: "polygon",
|
||||
filter: "planet_osm_polygon.natural = 'beach'",
|
||||
},
|
||||
{
|
||||
name: "Military",
|
||||
type: "polygon",
|
||||
filter: "military IS NOT null OR landuse = 'military'",
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user