mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-10-12 18:07:07 +02:00
dockerize
This commit is contained in:
1 parent
ff0aff59f4
commit
fd94bd3cee
18 files changed
+359
-133
No files matched your search
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Stage 1: Build the frontend
|
||||||
|
FROM node:18 AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY webapp/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY webapp ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build the Scala app
|
||||||
|
FROM hseeberger/scala-sbt:8u222_1.3.5_2.13.1 AS scala-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY shotgun/project/ ./project
|
||||||
|
COPY shotgun/build.sbt ./build.sbt
|
||||||
|
COPY shotgun/ ./
|
||||||
|
COPY --from=frontend-build /app/frontend/dist/ ../webapp/dist/
|
||||||
|
RUN sbt assembly
|
||||||
|
|
||||||
|
# Stage 3: Run the Scala app
|
||||||
|
FROM openjdk:11-jre-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=scala-build /app/target/scala-2.12/shotgun-assembly-0.1.0-SNAPSHOT.jar ./shotgun-assembly-0.1.0-SNAPSHOT.jar
|
||||||
|
COPY --from=frontend-build /app/frontend/dist/ ../webapp/dist/
|
||||||
|
CMD ["java", "-jar", "shotgun-assembly-0.1.0-SNAPSHOT.jar"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
deflock:
|
||||||
|
image: public.ecr.aws/w2o0b9g0/deflock.me:latest
|
||||||
|
expose:
|
||||||
|
- 8080
|
||||||
|
#restart: always
|
||||||
|
stdin_open: true
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt
|
||||||
|
environment:
|
||||||
|
- SSL_CERTIFICATE=/etc/letsencrypt/live/deflock.me/fullchain.pem
|
||||||
|
- SSL_CERTIFICATE_KEY=/etc/letsencrypt/live/deflock.me/privkey.pem
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
environment:
|
||||||
|
- CERTBOT_EMAIL
|
||||||
|
volumes:
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt
|
||||||
|
- /var/lib/letsencrypt:/var/lib/letsencrypt
|
||||||
|
command: certonly --webroot -w /var/www/certbot -d deflock.me --email ${CERTBOT_EMAIL} --agree-tos --no-eff-email
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name deflock.me www.deflock.me;
|
||||||
|
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name deflock.me www.deflock.me;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/deflock.me/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/deflock.me/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://deflock:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.10%252B7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.10_7.tar.gz/jdk-17.0.10+7/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt","xsbt.boot.Boot","-bsp"]}
|
{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Java/JavaVirtualMachines/corretto-11.0.19/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Application Support/JetBrains/IdeaIC2024.1/plugins/Scala/launcher/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Application%20Support/Coursier/bin/sbt","xsbt.boot.Boot","-bsp"]}
|
||||||
+3
-4
@@ -17,8 +17,7 @@ libraryDependencies ++= Seq(
|
|||||||
"org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion,
|
"org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion,
|
||||||
"org.apache.pekko" %% "pekko-stream" % PekkoVersion,
|
"org.apache.pekko" %% "pekko-stream" % PekkoVersion,
|
||||||
"org.apache.pekko" %% "pekko-http" % PekkoHttpVersion,
|
"org.apache.pekko" %% "pekko-http" % PekkoHttpVersion,
|
||||||
"io.circe" %% "circe-core" % "0.14.7",
|
"org.apache.pekko" %% "pekko-http-spray-json" % PekkoHttpVersion,
|
||||||
"io.circe" %% "circe-generic" % "0.14.9",
|
"org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion,
|
||||||
"io.circe" %% "circe-parser" % "0.14.9",
|
"org.apache.pekko" %% "pekko-slf4j" % PekkoVersion,
|
||||||
"org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion
|
|
||||||
)
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")
|
||||||
@@ -1,25 +1,65 @@
|
|||||||
package me.deflock.shotgun
|
package me.deflock.shotgun
|
||||||
|
|
||||||
import org.apache.pekko
|
import org.apache.pekko
|
||||||
import org.apache.pekko.http.scaladsl.model.headers.{HttpOrigin, HttpOriginRange, `Access-Control-Allow-Origin`}
|
import org.apache.pekko.actor.ActorSystem
|
||||||
import pekko.actor.typed.ActorSystem
|
import org.apache.pekko.event.Logging
|
||||||
import pekko.actor.typed.scaladsl.Behaviors
|
import org.apache.pekko.http.cors.scaladsl.CorsDirectives.cors
|
||||||
|
import org.apache.pekko.http.cors.scaladsl.model.HttpOriginMatcher
|
||||||
|
import org.apache.pekko.http.cors.scaladsl.settings.CorsSettings
|
||||||
|
import org.apache.pekko.http.scaladsl.model.headers.{HttpOrigin, `Access-Control-Allow-Origin`}
|
||||||
import pekko.http.scaladsl.Http
|
import pekko.http.scaladsl.Http
|
||||||
import pekko.http.scaladsl.model._
|
import pekko.http.scaladsl.model._
|
||||||
import pekko.http.scaladsl.server.Directives._
|
import pekko.http.scaladsl.server.Directives.{path, _}
|
||||||
|
import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
|
||||||
|
import org.apache.pekko.http.scaladsl.server.RejectionHandler
|
||||||
|
|
||||||
import scala.concurrent.ExecutionContextExecutor
|
import scala.concurrent.{Await, ExecutionContextExecutor, Future}
|
||||||
|
import scala.concurrent.duration._
|
||||||
import scala.io.StdIn
|
import scala.io.StdIn
|
||||||
|
|
||||||
object ShotgunServer {
|
object ShotgunServer {
|
||||||
|
|
||||||
def main(args: Array[String]): Unit = {
|
def main(args: Array[String]): Unit = {
|
||||||
|
|
||||||
implicit val system: ActorSystem[Any] = ActorSystem(Behaviors.empty, "my-system")
|
implicit val system: ActorSystem = ActorSystem("my-system")
|
||||||
implicit val executionContext: ExecutionContextExecutor = system.executionContext
|
implicit val executionContext: ExecutionContextExecutor = system.dispatcher
|
||||||
|
val logging = Logging(system, getClass)
|
||||||
|
|
||||||
val routes = {
|
val client = new services.OverpassClient()
|
||||||
concat {
|
|
||||||
|
// CORS
|
||||||
|
val allowedOrigins = List(
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"https://deflock.me",
|
||||||
|
"https://www.deflock.me",
|
||||||
|
).map(HttpOrigin(_)) // TODO: make this a config setting
|
||||||
|
val corsSettings = CorsSettings.default
|
||||||
|
.withAllowedOrigins(HttpOriginMatcher(allowedOrigins: _*))
|
||||||
|
.withExposedHeaders(List(`Access-Control-Allow-Origin`.name))
|
||||||
|
|
||||||
|
val rejectionHandler = RejectionHandler.newBuilder()
|
||||||
|
.handleNotFound {
|
||||||
|
complete((StatusCodes.NotFound, "The requested resource could not be found."))
|
||||||
|
}
|
||||||
|
.handle {
|
||||||
|
case corsRejection: org.apache.pekko.http.cors.scaladsl.CorsRejection =>
|
||||||
|
complete((StatusCodes.Forbidden, "CORS rejection: Invalid origin"))
|
||||||
|
}
|
||||||
|
.result()
|
||||||
|
|
||||||
|
val apiRoutes = pathPrefix("api") {
|
||||||
|
concat (
|
||||||
|
path("alpr") {
|
||||||
|
get {
|
||||||
|
parameters("minLat".as[Double], "minLng".as[Double], "maxLat".as[Double], "maxLng".as[Double]) { (minLat, minLng, maxLat, maxLng) =>
|
||||||
|
val bBox = services.BoundingBox(minLat, minLng, maxLat, maxLng)
|
||||||
|
onSuccess(client.getALPRs(bBox)) { json =>
|
||||||
|
complete(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
path("oauth2" / "callback") {
|
path("oauth2" / "callback") {
|
||||||
get {
|
get {
|
||||||
parameters(Symbol("code").?) { (code) =>
|
parameters(Symbol("code").?) { (code) =>
|
||||||
@@ -27,23 +67,20 @@ object ShotgunServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
path("alpr") {
|
)
|
||||||
get {
|
}
|
||||||
parameters("minLat".as[Double], "minLng".as[Double], "maxLat".as[Double], "maxLng".as[Double]) { (minLat, minLng, maxLat, maxLng) =>
|
|
||||||
val client = new services.OverpassClient() // TODO: make this global
|
val spaRoutes = pathSingleSlash {
|
||||||
val bBox = services.BoundingBox(minLat, minLng, maxLat, maxLng)
|
getFromFile("../webapp/dist/index.html")
|
||||||
onSuccess(client.getALPRs(bBox)) { json =>
|
} ~ getFromDirectory("../webapp/dist")
|
||||||
respondWithHeader(`Access-Control-Allow-Origin`.*) {
|
|
||||||
complete(HttpEntity(ContentTypes.`application/json`, json.toString()))
|
val routes = handleRejections(rejectionHandler) {
|
||||||
}
|
cors(corsSettings) {
|
||||||
}
|
concat(apiRoutes, spaRoutes)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val bindingFuture = Http().newServerAt("localhost", 8080).bind(routes)
|
val bindingFuture = Http().newServerAt("0.0.0.0", 8080).bind(routes)
|
||||||
|
|
||||||
println(s"Server now online. Please navigate to http://localhost:8080\nPress RETURN to stop...")
|
println(s"Server now online. Please navigate to http://localhost:8080\nPress RETURN to stop...")
|
||||||
StdIn.readLine() // let it run until user presses return
|
StdIn.readLine() // let it run until user presses return
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import org.apache.pekko
|
import org.apache.pekko
|
||||||
import pekko.actor.typed.ActorSystem
|
import org.apache.pekko.actor.ActorSystem
|
||||||
import pekko.actor.typed.scaladsl.Behaviors
|
|
||||||
import pekko.http.scaladsl.Http
|
import pekko.http.scaladsl.Http
|
||||||
import pekko.http.scaladsl.model._
|
import pekko.http.scaladsl.model._
|
||||||
import pekko.http.scaladsl.unmarshalling.Unmarshal
|
import pekko.http.scaladsl.unmarshalling.Unmarshal
|
||||||
import io.circe._
|
import spray.json._
|
||||||
import io.circe.parser._
|
import DefaultJsonProtocol._
|
||||||
import org.apache.pekko.http.scaladsl.client.RequestBuilding._
|
|
||||||
|
|
||||||
import scala.concurrent.duration.DurationInt
|
|
||||||
import scala.concurrent.{ExecutionContextExecutor, Future}
|
import scala.concurrent.{ExecutionContextExecutor, Future}
|
||||||
import scala.util.{Failure, Success}
|
|
||||||
|
|
||||||
case class BoundingBox(minLat: Double, minLng: Double, maxLat: Double, maxLng: Double)
|
case class BoundingBox(minLat: Double, minLng: Double, maxLat: Double, maxLng: Double)
|
||||||
|
|
||||||
class OverpassClient(implicit val system: ActorSystem[_], implicit val executionContext: ExecutionContextExecutor) {
|
class OverpassClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) {
|
||||||
val baseUrl = "https://overpass-api.de/api/interpreter"
|
val baseUrl = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
||||||
def getALPRs(bBox: BoundingBox): Future[Json] = {
|
def getALPRs(bBox: BoundingBox): Future[JsValue] = {
|
||||||
val query = s"""[out:json][bbox:${bBox.minLat},${bBox.minLng},${bBox.maxLat},${bBox.maxLng}];node["man_made"="surveillance"]["surveillance:type"="ALPR"];out body;>;out skel qt;"""
|
val query = s"""[out:json][bbox:${bBox.minLat},${bBox.minLng},${bBox.maxLat},${bBox.maxLng}];node["man_made"="surveillance"]["surveillance:type"="ALPR"];out body;>;out skel qt;"""
|
||||||
val formData = FormData("data" -> query).toEntity
|
val formData = FormData("data" -> query).toEntity
|
||||||
val request = HttpRequest(
|
val request = HttpRequest(
|
||||||
@@ -29,8 +24,14 @@ class OverpassClient(implicit val system: ActorSystem[_], implicit val execution
|
|||||||
)
|
)
|
||||||
|
|
||||||
Http().singleRequest(request).flatMap { response =>
|
Http().singleRequest(request).flatMap { response =>
|
||||||
response.entity.toStrict(5.seconds).flatMap { entity =>
|
response.status match {
|
||||||
Unmarshal(entity).to[String].map(parse(_).getOrElse(Json.Null))
|
case StatusCodes.OK =>
|
||||||
|
Unmarshal(response.entity).to[String].map { jsonString =>
|
||||||
|
jsonString.parseJson
|
||||||
|
}
|
||||||
|
case _ =>
|
||||||
|
response.discardEntityBytes()
|
||||||
|
Future.failed(new Exception(s"Failed to get ALPRs: ${response.status}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 489 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
+1
-7
@@ -20,16 +20,10 @@ const drawer = ref(false)
|
|||||||
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
|
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
|
||||||
|
|
||||||
<v-toolbar-title>
|
<v-toolbar-title>
|
||||||
<v-img height="36" width="200" src="/deflock-logo.svg" />
|
<v-img height="36" width="130" src="/deflock-logo.svg" />
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<template v-if="$vuetify.display.mdAndUp">
|
|
||||||
<v-btn disabled icon="mdi-magnify" variant="text"></v-btn>
|
|
||||||
|
|
||||||
<v-btn disabled icon="mdi-filter" variant="text"></v-btn>
|
|
||||||
</template>
|
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
|
|
||||||
<v-navigation-drawer
|
<v-navigation-drawer
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
a {
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgb(18, 151, 195);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div style="position: relative">
|
||||||
|
<v-btn @click="copyToClipboard" variant="plain" flat class="copy-button">
|
||||||
|
<v-icon>mdi-content-copy</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<code ref="codeContent">
|
||||||
|
<slot></slot>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const codeContent = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
if (codeContent.value) {
|
||||||
|
navigator.clipboard.writeText(codeContent.value.innerText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
code {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,44 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface BoundingBox {
|
export interface BoundingBoxLiteral {
|
||||||
minLat: number;
|
minLat: number;
|
||||||
maxLat: number;
|
maxLat: number;
|
||||||
minLng: number;
|
minLng: number;
|
||||||
maxLng: number;
|
maxLng: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BoundingBox implements BoundingBoxLiteral {
|
||||||
|
minLat: number;
|
||||||
|
maxLat: number;
|
||||||
|
minLng: number;
|
||||||
|
maxLng: number;
|
||||||
|
|
||||||
|
constructor({minLat, maxLat, minLng, maxLng}: BoundingBoxLiteral) {
|
||||||
|
this.minLat = minLat;
|
||||||
|
this.maxLat = maxLat;
|
||||||
|
this.minLng = minLng;
|
||||||
|
this.maxLng = maxLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromOther(boundingBoxLiteral: BoundingBoxLiteral) {
|
||||||
|
this.minLat = boundingBoxLiteral.minLat;
|
||||||
|
this.maxLat = boundingBoxLiteral.maxLat;
|
||||||
|
this.minLng = boundingBoxLiteral.minLng;
|
||||||
|
this.maxLng = boundingBoxLiteral.maxLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubsetOf(other: BoundingBoxLiteral) {
|
||||||
|
return (
|
||||||
|
this.minLat >= other.minLat &&
|
||||||
|
this.maxLat <= other.maxLat &&
|
||||||
|
this.minLng >= other.minLng &&
|
||||||
|
this.maxLng <= other.maxLng
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apiService = axios.create({
|
const apiService = axios.create({
|
||||||
baseURL: "http://localhost:8080",
|
baseURL: window.location.hostname === "localhost" ? "http://localhost:8080/api" : "/api",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,11 +20,14 @@
|
|||||||
name="OpenStreetMap"
|
name="OpenStreetMap"
|
||||||
></l-tile-layer>
|
></l-tile-layer>
|
||||||
<l-marker
|
<l-marker
|
||||||
@click="console.log('marker clicked')"
|
|
||||||
v-for="alpr in alprsInView"
|
v-for="alpr in alprsInView"
|
||||||
:key="alpr.id"
|
:key="alpr.id"
|
||||||
:lat-lng="[alpr.lat, alpr.lon]"
|
:lat-lng="[alpr.lat, alpr.lon]"
|
||||||
><l-popup>This is an ALPR! More data (such as direction) coming soon.</l-popup></l-marker>
|
><l-popup>
|
||||||
|
<h2>ALPR</h2>
|
||||||
|
<p v-if="alpr.tags.brand || alpr.tags.operator"><strong>Brand: </strong><a target="_blank" :href="`https://www.wikidata.org/wiki/${alpr.tags['brand:wikidata'] || alpr.tags['operator:wikidata']}`">{{ alpr.tags.brand || alpr.tags.operator || 'Unknown' }}</a></p>
|
||||||
|
<p v-if="alpr.tags.direction"><strong>Faces: {{ degreesToCardinal(alpr.tags.direction) }}</strong></p>
|
||||||
|
</l-popup></l-marker>
|
||||||
</l-map>
|
</l-map>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
loading...
|
loading...
|
||||||
@@ -38,7 +41,7 @@ import { LMap, LTileLayer, LMarker, LPopup } from '@vue-leaflet/vue-leaflet';
|
|||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import type { BoundingBox } from '@/services/apiService';
|
import { BoundingBox } from '@/services/apiService';
|
||||||
import { getALPRs } from '@/services/apiService';
|
import { getALPRs } from '@/services/apiService';
|
||||||
|
|
||||||
const zoom: Ref<number> = ref(12);
|
const zoom: Ref<number> = ref(12);
|
||||||
@@ -48,46 +51,8 @@ const router = useRouter();
|
|||||||
|
|
||||||
const canRefreshMarkers = computed(() => zoom.value >= 10);
|
const canRefreshMarkers = computed(() => zoom.value >= 10);
|
||||||
|
|
||||||
const alprsInView: Ref<any[]> = ref([
|
const alprsInView: Ref<any[]> = ref([]);
|
||||||
// {
|
const bboxForLastRequest: Ref<BoundingBox|null> = ref(null);
|
||||||
// "type": "node",
|
|
||||||
// "id": 12187369976,
|
|
||||||
// "lat": 34.6616103,
|
|
||||||
// "lon": -86.4870137,
|
|
||||||
// "tags": {
|
|
||||||
// "brand": "Flock Safety",
|
|
||||||
// "brand:wikidata": "Q108485435",
|
|
||||||
// "camera:mount": "pole",
|
|
||||||
// "camera:type": "fixed",
|
|
||||||
// "direction": "335",
|
|
||||||
// "man_made": "surveillance",
|
|
||||||
// "operator": "Flock Safety",
|
|
||||||
// "operator:wikidata": "Q108485435",
|
|
||||||
// "surveillance": "traffic",
|
|
||||||
// "surveillance:type": "ALPR",
|
|
||||||
// "surveillance:zone": "traffic"
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// "type": "node",
|
|
||||||
// "id": 12187369977,
|
|
||||||
// "lat": 34.6615727,
|
|
||||||
// "lon": -86.4881948,
|
|
||||||
// "tags": {
|
|
||||||
// "brand": "Flock Safety",
|
|
||||||
// "brand:wikidata": "Q108485435",
|
|
||||||
// "camera:mount": "pole",
|
|
||||||
// "camera:type": "fixed",
|
|
||||||
// "direction": "295",
|
|
||||||
// "man_made": "surveillance",
|
|
||||||
// "operator": "Flock Safety",
|
|
||||||
// "operator:wikidata": "Q108485435",
|
|
||||||
// "surveillance": "traffic",
|
|
||||||
// "surveillance:type": "ALPR",
|
|
||||||
// "surveillance:zone": "traffic"
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getUserLocation(): Promise<[number, number]> {
|
function getUserLocation(): Promise<[number, number]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -115,18 +80,22 @@ function mapLoaded(map: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateBounds(newBounds: any) {
|
function updateBounds(newBounds: any) {
|
||||||
bounds.value = {
|
updateURL();
|
||||||
|
|
||||||
|
const newBoundingBox = new BoundingBox({
|
||||||
minLat: newBounds.getSouth(),
|
minLat: newBounds.getSouth(),
|
||||||
maxLat: newBounds.getNorth(),
|
maxLat: newBounds.getNorth(),
|
||||||
minLng: newBounds.getWest(),
|
minLng: newBounds.getWest(),
|
||||||
maxLng: newBounds.getEast(),
|
maxLng: newBounds.getEast(),
|
||||||
};
|
});
|
||||||
|
bounds.value = newBoundingBox;
|
||||||
|
|
||||||
|
if (bboxForLastRequest.value && newBoundingBox.isSubsetOf(bboxForLastRequest.value)) {
|
||||||
|
console.debug('new bounds are a subset of the last request, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateMarkers();
|
updateMarkers();
|
||||||
|
|
||||||
if (center.value) {
|
|
||||||
updateURL();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateURL() {
|
function updateURL() {
|
||||||
@@ -153,14 +122,19 @@ function updateMarkers() {
|
|||||||
getALPRs(bounds.value)
|
getALPRs(bounds.value)
|
||||||
.then((alprs: any) => {
|
.then((alprs: any) => {
|
||||||
alprsInView.value = alprs.elements;
|
alprsInView.value = alprs.elements;
|
||||||
|
bboxForLastRequest.value = bounds.value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function degreesToCardinal(degrees: number): string {
|
||||||
|
const cardinals = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||||
|
return cardinals[Math.round(degrees / 45) % 8];
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const hash = router.currentRoute.value.hash;
|
const hash = router.currentRoute.value.hash;
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const parts = hash.split('/');
|
const parts = hash.split('/');
|
||||||
console.log('parts', parts);
|
|
||||||
if (parts.length === 3 && parts[0].startsWith('#map')) {
|
if (parts.length === 3 && parts[0].startsWith('#map')) {
|
||||||
const zoomLevelString = parts[0].replace('#map=', '');
|
const zoomLevelString = parts[0].replace('#map=', '');
|
||||||
zoom.value = parseInt(zoomLevelString, 10);
|
zoom.value = parseInt(zoomLevelString, 10);
|
||||||
@@ -168,8 +142,6 @@ onMounted(() => {
|
|||||||
lat: parseFloat(parts[1]),
|
lat: parseFloat(parts[1]),
|
||||||
lng: parseFloat(parts[2]),
|
lng: parseFloat(parts[2]),
|
||||||
};
|
};
|
||||||
console.log('center', center.value);
|
|
||||||
console.log('zoom', zoom.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +168,9 @@ onMounted(() => {
|
|||||||
left: 32px;
|
left: 32px;
|
||||||
width: calc(100% - 64px);
|
width: calc(100% - 64px);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
color: #333;
|
color: #eee;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,33 +2,85 @@
|
|||||||
<v-container>
|
<v-container>
|
||||||
<h2>How to Report an ALPR</h2>
|
<h2>How to Report an ALPR</h2>
|
||||||
<p>
|
<p>
|
||||||
If you've spotted an ALPR in your area, you can help us track it by reporting it to our database. Here's how you can do it:
|
If you've spotted an ALPR in your area, you can help us track it by reporting it to OpenStreetMap, where we source our information. Here's how you can do it:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Coming Soon</h3>
|
<v-alert
|
||||||
<p>
|
variant="tonal"
|
||||||
We're working on a way for you to report ALPRs directly from this site. Check back soon for updates!
|
type="warning"
|
||||||
</p>
|
class="my-6"
|
||||||
<p>
|
title="Are you sure it's an ALPR?"
|
||||||
Until then, you can report them on OpenStreetMap. <a href="https://wiki.openstreetmap.org/wiki/Tag:man_made%3Dsurveillance" target="_blank">Learn how to add ALPRs to OpenStreetMap</a>.
|
>
|
||||||
</p>
|
<p>
|
||||||
|
Before you report an ALPR, please read our <router-link style="color: unset !important" to="/what-is-an-alpr">guide on what ALPRs look like</router-link> to make sure you're reporting the right thing.
|
||||||
|
</p>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
<!-- <p>
|
<div class="ml-4 mt-4">
|
||||||
If you can do so safely, take a picture of the ALPR camera. Make sure the camera is visible in the image, and try to capture any identifying information, such as the brand name or any logos.
|
<h3>1. Create an OpenStreetMap Account</h3>
|
||||||
</p>
|
<p>
|
||||||
|
<a href="https://www.openstreetmap.org/user/new" target="_blank">Sign up for an OpenStreetMap account</a> in order to submit changes.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>Step 2: Note the Location</h3>
|
<h3>2. Find the ALPR's Location on OpenStreetMap</h3>
|
||||||
<p>
|
<p>
|
||||||
Record the location of the ALPR camera. You can use your phone's GPS to get the coordinates, or note the nearest address or intersection.
|
<a href="https://www.openstreetmap.org" target="_blank">Launch OpenStreetMap</a> and search for the location of the ALPR. You can use the search bar at the top of the page to find the location.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Step 3: Submit the Report</h3>
|
<h3>3. Add the ALPR to OpenStreetMap</h3>
|
||||||
<p>
|
<p>
|
||||||
Click the "Report" button below to submit your report. You'll be asked to upload the picture you took and provide the location information. Your report will help us build a more comprehensive database of ALPRs in the United States.
|
Once you've found the location of the ALPR, click the <strong>Edit</strong> button in the top left corner of the page. This will open the OpenStreetMap editor, where you can add the ALPR to the map.
|
||||||
</p> -->
|
</p>
|
||||||
|
|
||||||
|
<v-img src="/edit-map.png" />
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To add the ALPR, click the <strong>Point</strong> button in the top left corner of the editor, then click on the location of the ALPR on the map. In the popup that appears, paste the following tags:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DFCode>
|
||||||
|
man_made=surveillance<br>
|
||||||
|
surveillance:type=ALPR<br>
|
||||||
|
camera:mount=pole<br>
|
||||||
|
camera:type=fixed<br>
|
||||||
|
surveillance=traffic<br>
|
||||||
|
surveillance:zone=traffic<br>
|
||||||
|
</DFCode>
|
||||||
|
|
||||||
|
<v-img class="my-4" src="/paste-tags.png" />
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you've identified the brand of the ALPR as Flock Safety, then you can also add the following tags:
|
||||||
|
</p>
|
||||||
|
<DFCode>
|
||||||
|
operator=Flock Safety<br>
|
||||||
|
operator:wikidata=Q108485435<br>
|
||||||
|
brand=Flock Safety<br>
|
||||||
|
brand:wikidata=Q108485435<br>
|
||||||
|
</DFCode>
|
||||||
|
|
||||||
|
<h3>4. Adjust the Direction</h3>
|
||||||
|
|
||||||
|
<v-img src="/adjust-angle.png" />
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you know the direction that the ALPR is facing, you can use the up and down arrows to set the direction it faces.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>5. Submit Your Changes</h3>
|
||||||
|
<p>
|
||||||
|
Once you've added the ALPR to the map, click the <strong>Save</strong> button in the top left corner of the editor. You'll be asked to provide a brief description of your changes. Once you've submitted your changes, the ALPR will be added to OpenStreetMap.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DFCode from '@/components/DFCode.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* TODO: put this all in one place, also in what-is view */
|
/* TODO: put this all in one place, also in what-is view */
|
||||||
h2 {
|
h2 {
|
||||||
@@ -50,16 +102,11 @@ p {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
code {
|
||||||
color: var(--v-primary-base);
|
background-color: #f4f4f4;
|
||||||
text-decoration: none;
|
padding: 0.5rem;
|
||||||
}
|
border-radius: 0.25rem;
|
||||||
|
display: block;
|
||||||
a:hover {
|
margin-top: 0.5rem;
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited {
|
|
||||||
color: var(--v-primary-base);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user