This commit is contained in:
Logan Williams
2022-10-05 10:56:44 +02:00
parent 5dfb53fa90
commit ed5473b6e5
37 changed files with 585 additions and 0 deletions

View 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 }}&nbsp;
<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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'",
},
];