suroh
1 year ago
36 changed files with 9696 additions and 0 deletions
@ -0,0 +1,9 @@ |
|||
# ethermap |
|||
|
|||
> This is very much a janky _earlydays_ project. All help is welcome! |
|||
|
|||
An interactive map tool. A tool for collaborative planning on maps. Anyone can create new maps, and add, modify and delete locations on any map. |
|||
|
|||
## Install |
|||
|
|||
Ethermap is built in JavaScript (soz) using NodeJS. Install instructinos for the back and frontend are in their respective folders. |
@ -0,0 +1,14 @@ |
|||
# ethermap |
|||
SITE_NAME= |
|||
|
|||
# Server Setup |
|||
PORT= |
|||
|
|||
# Database Setup |
|||
DB_PROVIDER= |
|||
DB_HOST= |
|||
DB_PORT= |
|||
DB_NAME= |
|||
DB_USER= |
|||
DB_PASS= |
|||
|
@ -0,0 +1,31 @@ |
|||
# ethermap api |
|||
|
|||
Backend for ethermap |
|||
|
|||
## Install |
|||
|
|||
To install and run the backen you will need [NodeJS](https://nodejs.org/en) and `npm` installed, along with access to Postgresql server (possibility for this to be any database server). Then : |
|||
|
|||
```sh |
|||
$ npm i |
|||
``` |
|||
|
|||
Once all the packages are installed you should setup your `.env` file (follow the `.env.template`). Once this has all the appropriate entries you can then connect and migrate the database. |
|||
|
|||
```sh |
|||
$ npm run migrate:latest |
|||
``` |
|||
|
|||
then to run the development server you should run : |
|||
|
|||
```sh |
|||
$ npm run dev |
|||
``` |
|||
|
|||
## Tech |
|||
|
|||
The backend is made up of a REST api and websocket server. The REST api is built on [Express](https://expressjs.com/) and the websocket server is built on [socket.io](https://socket.io/). |
|||
|
|||
Database interface is the ODM [objection.js](https://vincit.github.io/objection.js/). This setup might not be the best as it was adopted mid-project after starting with just [Knex](https://knexjs.org/) alone. |
|||
|
|||
Tests are written in [Ava](https://github.com/avajs/ava). |
@ -0,0 +1,25 @@ |
|||
import MapModel from '../db/models/map.js' |
|||
|
|||
const getAllMaps = async (req, res) => { |
|||
const maps = await MapModel.query() |
|||
|
|||
res.json({ maps }) |
|||
} |
|||
|
|||
const getMapByName = async (req, res) => { |
|||
const name = req.params.mapName |
|||
try { |
|||
let map = await MapModel.query().where({ name }).withGraphFetched('map_points').first() |
|||
|
|||
if (!map) { |
|||
const created = await MapModel.query().insert({ name }) |
|||
map = await MapModel.query().findById(created.id).withGraphFetched('map_points') |
|||
} |
|||
|
|||
res.json(map) |
|||
} catch (error) { |
|||
console.error(error) |
|||
} |
|||
} |
|||
|
|||
export { getMapByName, getAllMaps } |
@ -0,0 +1,18 @@ |
|||
import MapModel from '../db/models/map.js' |
|||
|
|||
const setPoint = async (req, res, next) => { |
|||
try { |
|||
const { mapId, point } = req.body |
|||
|
|||
console.log(req.body) |
|||
|
|||
const map = await MapModel.query().findById(mapId) |
|||
const p = await map.$relatedQuery('map_points').insert(point) |
|||
|
|||
res.json(p) |
|||
} catch (err) { |
|||
next(err) |
|||
} |
|||
} |
|||
|
|||
export { setPoint } |
@ -0,0 +1,23 @@ |
|||
import knexConfig from '../knexfile.js' |
|||
import Knex from 'knex' |
|||
import { Model } from 'objection' |
|||
import { newDb } from 'pg-mem' |
|||
|
|||
const environment = process.env.NODE_ENV || 'development' |
|||
|
|||
// variable for exporting the db
|
|||
let DB |
|||
|
|||
if (environment == 'test') { |
|||
const mem = newDb() |
|||
DB = mem.adapters.createKnex(0, { |
|||
migrations: { |
|||
directory: './db/migrations' |
|||
}, |
|||
}) |
|||
Model.knex(DB) |
|||
} else { |
|||
DB = Knex(knexConfig[environment]) |
|||
} |
|||
|
|||
export default DB |
@ -0,0 +1,34 @@ |
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
const up = (knex) => { |
|||
return knex.schema |
|||
.createTable('maps', (table) => { |
|||
table.increments().primary() |
|||
table.string('name').notNullable().unique() |
|||
table.timestamp('created_at').defaultTo(knex.fn.now()) |
|||
table.timestamp('updated_at').defaultTo(knex.fn.now()) |
|||
}) |
|||
.createTable('map_points', (table) => { |
|||
table.increments().primary() |
|||
table.string('name') |
|||
table.string('notes') |
|||
table.point('location') |
|||
table.timestamp('created_at').defaultTo(knex.fn.now()) |
|||
table.timestamp('updated_at').defaultTo(knex.fn.now()) |
|||
table.integer('map_id').references('id').inTable('maps') |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
const down = (knex) => { |
|||
return knex.schema |
|||
.raw('DROP TABLE maps CASCADE') |
|||
.dropTable('map_points') |
|||
} |
|||
|
|||
export { up, down } |
@ -0,0 +1,21 @@ |
|||
import { Model } from 'objection' |
|||
import Point from './point.js' |
|||
|
|||
class MapModel extends Model { |
|||
static tableName = 'maps' |
|||
|
|||
static get relationMappings() { |
|||
return { |
|||
map_points: { |
|||
relation: Model.HasManyRelation, |
|||
modelClass: Point, |
|||
join: { |
|||
from: 'maps.id', |
|||
to: 'map_points.map_id', |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
export default MapModel |
@ -0,0 +1,7 @@ |
|||
import { Model } from 'objection' |
|||
|
|||
class PointModel extends Model { |
|||
static tableName = 'map_points' |
|||
} |
|||
|
|||
export default PointModel |
@ -0,0 +1,30 @@ |
|||
// web server
|
|||
import express from 'express' |
|||
import cors from 'cors' |
|||
|
|||
// database
|
|||
import DB from './db/DB.js' |
|||
import { Model } from 'objection' |
|||
|
|||
// middleware
|
|||
import ErrorMiddleware from './middleware/errors.js' |
|||
|
|||
// database setup
|
|||
Model.knex(DB) |
|||
|
|||
// webserver setup
|
|||
const app = express() |
|||
app.use(express.json()) |
|||
app.use(cors()) |
|||
|
|||
// home route
|
|||
app.get('/', (_, res) => res.send('ethermap')) |
|||
|
|||
// routes
|
|||
import apiRouter from './routes/api.js' |
|||
app.use('/api', apiRouter) |
|||
|
|||
// error middleware
|
|||
app.use(ErrorMiddleware) |
|||
|
|||
export default app |
@ -0,0 +1,17 @@ |
|||
import App from './express.js' |
|||
import 'dotenv/config' |
|||
|
|||
import { Socket } from './sockets.js' |
|||
import { Server } from 'socket.io' |
|||
|
|||
const server = App.listen(process.env.PORT, () => { |
|||
console.log(`Ethermap listening for connections on port ${process.env.PORT}`) |
|||
}) |
|||
|
|||
const io = new Server(server, { |
|||
cors: { |
|||
origin: 'http://localhost:5173' |
|||
} |
|||
}) |
|||
|
|||
Socket(io) |
@ -0,0 +1,64 @@ |
|||
// Update with your config settings.
|
|||
import 'dotenv/config' |
|||
import { dirname } from 'path' |
|||
import { fileURLToPath } from 'url' |
|||
|
|||
const __dirname = dirname(fileURLToPath(import.meta.url)) |
|||
|
|||
/** |
|||
* @type { Object.<string, import("knex").Knex.Config> } |
|||
*/ |
|||
export default { |
|||
development: { |
|||
client: 'pg', |
|||
connection: { |
|||
database: process.env.DB_NAME, |
|||
user: process.env.DB_USER, |
|||
password: process.env.DB_PASS, |
|||
host: process.env.DB_HOST, |
|||
port: process.env.DB_PORT |
|||
}, |
|||
migrations: { |
|||
directory: __dirname + '/db/migrations' |
|||
}, |
|||
seeds: { |
|||
directory: __dirname + '/db/seeds' |
|||
} |
|||
}, |
|||
|
|||
staging: { |
|||
client: 'pg', |
|||
connection: { |
|||
database: process.env.DB_NAME, |
|||
user: process.env.DB_USER, |
|||
password: process.env.DB_PASS, |
|||
host: process.env.DB_HOST, |
|||
port: process.env.DB_PORT |
|||
}, |
|||
pool: { |
|||
min: 2, |
|||
max: 10 |
|||
}, |
|||
migrations: { |
|||
tableName: 'knex_migrations' |
|||
} |
|||
}, |
|||
|
|||
production: { |
|||
client: 'pg', |
|||
connection: { |
|||
database: process.env.DB_NAME, |
|||
user: process.env.DB_USER, |
|||
password: process.env.DB_PASS, |
|||
host: process.env.DB_HOST, |
|||
port: process.env.DB_PORT |
|||
}, |
|||
pool: { |
|||
min: 2, |
|||
max: 10 |
|||
}, |
|||
migrations: { |
|||
tableName: 'knex_migrations' |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,6 @@ |
|||
// TODO@me update error handler
|
|||
export default (err, _, res) => { |
|||
res.status(500).json({ message: err.message }) |
|||
} |
|||
|
|||
|
File diff suppressed because it is too large
@ -0,0 +1,38 @@ |
|||
{ |
|||
"name": "ethermap", |
|||
"version": "0.0.1", |
|||
"description": "collaborative map tool inspired by etherpad", |
|||
"main": "index.js", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "nodemon index.js", |
|||
"test": "ava", |
|||
"test:routes": "ava ./tests/routes.js", |
|||
"test:db": "ava ./tests/db.js", |
|||
"migrate:latest": "knex migrate:latest", |
|||
"migrate:drop": "knex migrate:down" |
|||
}, |
|||
"keywords": [ |
|||
"ethermap", |
|||
"map", |
|||
"collaborative" |
|||
], |
|||
"author": "", |
|||
"license": "GPL-3.0-or-later", |
|||
"dependencies": { |
|||
"cors": "^2.8.5", |
|||
"dotenv": "^16.3.1", |
|||
"express": "^4.18.2", |
|||
"knex": "^2.5.1", |
|||
"objection": "^3.1.1", |
|||
"pg": "^8.11.3", |
|||
"socket.io": "^4.7.2" |
|||
}, |
|||
"devDependencies": { |
|||
"ava": "^5.3.1", |
|||
"eslint": "^8.48.0", |
|||
"nodemon": "^3.0.1", |
|||
"pg-mem": "^2.6.13", |
|||
"supertest": "^6.3.3" |
|||
} |
|||
} |
@ -0,0 +1,15 @@ |
|||
import { Router } from 'express' |
|||
const router = Router() |
|||
|
|||
// MAPS
|
|||
import { getAllMaps } from '../controllers/maps.js' |
|||
import { getMapByName } from '../controllers/maps.js' |
|||
|
|||
router.get('/maps', getAllMaps) |
|||
router.get('/map/:mapName', getMapByName) |
|||
|
|||
// POINTS
|
|||
import { setPoint } from '../controllers/points.js' |
|||
router.post('/point/add', setPoint) |
|||
|
|||
export default router |
@ -0,0 +1,19 @@ |
|||
export const Socket = (io) => { |
|||
io.on('connection', (socket) => { |
|||
console.log('client connected with id', socket.id) |
|||
let roomId = null |
|||
|
|||
socket.on('connect-map', (mapId) => { |
|||
console.log('connect to map room', mapId) |
|||
|
|||
roomId = `map-${mapId}` |
|||
|
|||
socket.join(roomId) |
|||
}) |
|||
|
|||
socket.on('mousemove', (latlng) => { |
|||
socket.to(roomId).emit('mousemove', { id: socket.id, pos: latlng }) |
|||
}) |
|||
|
|||
}) |
|||
} |
@ -0,0 +1,37 @@ |
|||
// testing tools
|
|||
import test from 'ava' |
|||
import db from '../db/DB.js' |
|||
|
|||
// db model
|
|||
import MapModel from '../db/models/map.js' |
|||
|
|||
test.before(async () => { |
|||
await db.migrate.latest() |
|||
}) |
|||
|
|||
test('Selecting maps should return array', async t => { |
|||
const maps = await MapModel.query() |
|||
|
|||
t.truthy(maps) |
|||
}) |
|||
|
|||
test.serial('Inserting map returns map object', async t => { |
|||
const map = await MapModel.query().insert({ name: 'milo' }) |
|||
|
|||
t.is(map.name, 'milo') |
|||
}) |
|||
|
|||
test.serial('Insert point for existing map returns point', async t => { |
|||
const map = await MapModel.query().where({ name: 'milo' }).first() |
|||
const point = await map.$relatedQuery('map_points').insert({ |
|||
name: 'pointy', |
|||
location: '(50.8552,4.3454)', |
|||
}) |
|||
|
|||
t.is(point.name, 'pointy') |
|||
t.is(point.location, '(50.8552,4.3454)') |
|||
}) |
|||
|
|||
test.after(async () => { |
|||
await db.migrate.down() |
|||
}) |
@ -0,0 +1,93 @@ |
|||
// testing tools
|
|||
import test from 'ava' |
|||
import request from 'supertest' |
|||
|
|||
// express app
|
|||
import App from '../express.js' |
|||
import db from '../db/DB.js' |
|||
|
|||
test.before(async t => { |
|||
await db.migrate.latest() |
|||
}) |
|||
|
|||
test.serial('get "/" route should return body of "ethermap"', async t => { |
|||
const res = await request(App).get('/') |
|||
|
|||
t.is(res.status, 200) |
|||
t.is(res.text, 'ethermap') |
|||
}) |
|||
|
|||
test.serial('get "/api/maps" route should return an object containing an array called "maps"', async t => { |
|||
const res = await request(App).get('/api/maps') |
|||
|
|||
t.is(res.status, 200) |
|||
t.truthy(res.body.maps?.constructor === Array) |
|||
}) |
|||
|
|||
test.serial('get "/api/map/:mapName" route should return map with matching name', async t => { |
|||
const res = await request(App).get('/api/map/bingo') |
|||
|
|||
t.is(res.status, 200) |
|||
t.is(res.body.name, 'bingo') |
|||
}) |
|||
|
|||
test.serial('get "/api/map/:mapName" route with different mapName should create new map with different id', async t => { |
|||
const res = await request(App).get('/api/map/cheese') |
|||
|
|||
t.is(res.status, 200) |
|||
t.truthy(res.body.id) |
|||
t.not(res.body.id, 1) |
|||
}) |
|||
|
|||
test.serial('get "/api/map/:mapName" route with existing mapName should return same id', async t => { |
|||
const res = await request(App).get('/api/map/bingo') |
|||
|
|||
t.is(res.status, 200) |
|||
t.is(res.body.id, 1) |
|||
}) |
|||
|
|||
test.serial('post "/api/point/add" body containing a name, location and map_id should return a point', async t => { |
|||
const { body: { id: mapId } } = await request(App).get('/api/map/bingo') |
|||
const res = await request(App) |
|||
.post('/api/point/add') |
|||
.send({ |
|||
mapId, |
|||
point: { |
|||
name: 'pointy', |
|||
location: '(50.8552,4.3454)', |
|||
} |
|||
}) |
|||
|
|||
t.is(res.status, 200) |
|||
t.is(res.body.id, 1) |
|||
t.is(res.body.map_id, mapId) |
|||
t.is(res.body.name, 'pointy') |
|||
}) |
|||
|
|||
test.serial('get "/api/map/:mapName" with associated points should return a map with an array of points', async t => { |
|||
const res = await request(App).get('/api/map/bingo') |
|||
|
|||
t.is(res.status, 200) |
|||
t.truthy(res.body.map_points) |
|||
t.is(res.body.map_points.length, 1) |
|||
}) |
|||
|
|||
test.serial('post "/api/point/add" with incorrect data keys throws 500 error', async t => { |
|||
const { body: { id: mapId } } = await request(App).get('/api/map/bingo') |
|||
const error = await request(App) |
|||
.post('/api/point/add') |
|||
.send({ |
|||
mapId, |
|||
point: { |
|||
title: 'pointy', |
|||
coords: '(50.8552,4.3454)', |
|||
} |
|||
}) |
|||
|
|||
t.is(error.status, 500) |
|||
}) |
|||
|
|||
test.after(async () => { |
|||
await db.migrate.down() |
|||
}) |
|||
|
@ -0,0 +1,53 @@ |
|||
module.exports = { |
|||
root: true, |
|||
'env': { |
|||
'browser': true, |
|||
'node': true, |
|||
'es2019': true |
|||
}, |
|||
'extends': [ |
|||
'eslint:recommended', |
|||
'plugin:lit/recommended', |
|||
], |
|||
parserOptions: { |
|||
ecmaVersion: 'latest', |
|||
sourceType: 'module', |
|||
}, |
|||
'rules': { |
|||
'no-var': 'error', |
|||
'semi': [ |
|||
'error', |
|||
'never', |
|||
], |
|||
'quotes': [ |
|||
'error', |
|||
'single', |
|||
], |
|||
'object-curly-spacing': [ |
|||
'warn', |
|||
'always', |
|||
], |
|||
'array-bracket-spacing': [ |
|||
'warn', |
|||
'always', |
|||
], |
|||
'space-in-parens': [ |
|||
'warn', |
|||
'never', |
|||
], |
|||
'array-bracket-newline': [ |
|||
'warn', |
|||
'consistent', |
|||
], |
|||
'object-curly-newline': [ |
|||
'warn', |
|||
{ |
|||
'consistent': true, |
|||
}, |
|||
], |
|||
'space-before-blocks': [ |
|||
'warn', |
|||
'always', |
|||
], |
|||
}, |
|||
}; |
@ -0,0 +1,24 @@ |
|||
# Logs |
|||
logs |
|||
*.log |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
pnpm-debug.log* |
|||
lerna-debug.log* |
|||
|
|||
node_modules |
|||
dist |
|||
dist-ssr |
|||
*.local |
|||
|
|||
# Editor directories and files |
|||
.vscode/* |
|||
!.vscode/extensions.json |
|||
.idea |
|||
.DS_Store |
|||
*.suo |
|||
*.ntvs* |
|||
*.njsproj |
|||
*.sln |
|||
*.sw? |
@ -0,0 +1,25 @@ |
|||
# ethermap frontend |
|||
|
|||
Frontend for ethermap |
|||
|
|||
## Install |
|||
|
|||
To install the frontend you will need [NodeJS](https://nodejs.org/en) and `npm` installed. Then : |
|||
|
|||
```sh |
|||
$ npm i |
|||
``` |
|||
|
|||
To run the development server run : |
|||
|
|||
```sh |
|||
$ npm run dev |
|||
``` |
|||
|
|||
## Tech |
|||
|
|||
The interface is built with [LitElement](https://lit.dev/) and setup with [Vite](https://vitejs.dev/) bundler and dev server. |
|||
|
|||
Maps are rendered with [Leaflet](https://leafletjs.com). |
|||
|
|||
For ethermap to work you will also need to be running the [ethermap.api]() server for REST API and Socket.io connectivity. |
@ -0,0 +1,26 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<base href="/"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Vite App</title> |
|||
|
|||
<!-- leaflet --> |
|||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" |
|||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" |
|||
crossorigin=""/> |
|||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" |
|||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" |
|||
crossorigin=""></script> |
|||
|
|||
<!-- styles --> |
|||
<link rel="stylesheet" href="/styles/main.css"> |
|||
<!-- javascript --> |
|||
<script type="module" src="/src/EthermapApp.js"></script> |
|||
</head> |
|||
<body> |
|||
<ethermap-app></ethermap-app> |
|||
</body> |
|||
</html> |
File diff suppressed because it is too large
@ -0,0 +1,21 @@ |
|||
{ |
|||
"name": "ethermap.front", |
|||
"private": true, |
|||
"version": "0.0.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"build": "vite build", |
|||
"preview": "vite preview" |
|||
}, |
|||
"devDependencies": { |
|||
"eslint-plugin-lit": "^1.9.1", |
|||
"vite": "^4.4.5" |
|||
}, |
|||
"dependencies": { |
|||
"leaflet": "^1.9.4", |
|||
"leaflet-contextmenu": "^1.4.0", |
|||
"lit": "^2.8.0", |
|||
"socket.io-client": "^4.7.2" |
|||
} |
|||
} |
After Width: | Height: | Size: 951 B |
@ -0,0 +1,17 @@ |
|||
body { |
|||
font-family: sans-serif; |
|||
margin: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
nav { |
|||
padding-inline: 0.75em; |
|||
padding-block: 0.75em; |
|||
} |
|||
|
|||
main.map { |
|||
display: block; |
|||
flex-grow: 1; |
|||
} |
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,53 @@ |
|||
import { LitElement, html, css } from 'lit' |
|||
import './components/PointModal.js' |
|||
import Router from './controllers/Router.js' |
|||
|
|||
class EthermapApp extends LitElement { |
|||
static get properties() { |
|||
return { |
|||
route: {} |
|||
} |
|||
} |
|||
|
|||
constructor() { |
|||
super() |
|||
this.route = Router.render() |
|||
} |
|||
|
|||
firstUpdated() { |
|||
Router.addEventListener('route-changed', () => { |
|||
console.log('route-changed') |
|||
this.route = Router.render() |
|||
}) |
|||
|
|||
this.addEventListener('open-modal', (evt) => { |
|||
const modal = this.shadowRoot.querySelector('point-modal') |
|||
modal.open(evt.detail.mapId, evt.detail.latlng) |
|||
}) |
|||
} |
|||
|
|||
render() { |
|||
return html` |
|||
<nav>toolbar</nav> |
|||
${this.route} |
|||
<point-modal></point-modal> |
|||
` |
|||
} |
|||
|
|||
static get styles() { |
|||
return css` |
|||
:host { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100vh; |
|||
} |
|||
|
|||
nav { |
|||
padding-inline: 0.75em; |
|||
padding-block: 0.75em; |
|||
} |
|||
` |
|||
} |
|||
} |
|||
|
|||
window.customElements.define('ethermap-app', EthermapApp) |
@ -0,0 +1,122 @@ |
|||
import { unsafeCSS, LitElement, html, css } from 'lit' |
|||
import leafletCss from 'leaflet/dist/leaflet.css?inline' |
|||
import leafletContextCss from 'leaflet-contextmenu/dist/leaflet.contextmenu.css?inline' |
|||
import * as L from 'leaflet' |
|||
import 'leaflet-contextmenu' |
|||
|
|||
import { io } from 'socket.io-client' |
|||
|
|||
import MapController from '../controllers/MapController' |
|||
|
|||
class MapView extends LitElement { |
|||
mapController = new MapController(this) |
|||
|
|||
static get properties() { |
|||
return { |
|||
name: { type: String }, |
|||
map: { state: true }, |
|||
points: { type: Array, state: true }, |
|||
leaflet: { state: true }, |
|||
socket: { state: true }, |
|||
users: { state: true } |
|||
} |
|||
} |
|||
|
|||
constructor() { |
|||
super() |
|||
this.name = '' |
|||
this.leaflet = {} |
|||
this.points = [] |
|||
this.users = [] |
|||
} |
|||
|
|||
firstUpdated() { |
|||
// connect to socket
|
|||
this.socket = io('http://localhost:3000') |
|||
|
|||
// create leaflet map
|
|||
const mapEl = this.shadowRoot.querySelector('main') |
|||
this.leaflet = L.map(mapEl, { |
|||
contextmenu: true, |
|||
contextmenuWidth: 140, |
|||
contextmenuItems: [ { |
|||
text: 'create point', |
|||
callback: (evt) => { |
|||
const event = new CustomEvent('open-modal', { |
|||
bubbles: true, composed: true, |
|||
detail: { mapId: this.mapController.map.id, latlng: evt.latlng } |
|||
}) |
|||
|
|||
this.dispatchEvent(event) |
|||
} |
|||
} ] |
|||
}).setView([ 51.89190, 4.46920 ], 19) |
|||
|
|||
// set map tiles
|
|||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { |
|||
maxZoom: 19, |
|||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' |
|||
}).addTo(this.leaflet) |
|||
|
|||
|
|||
// track mouse movement
|
|||
this.leaflet.on('mousemove', (evt) => { |
|||
this.socket.emit('mousemove', evt.latlng) |
|||
}) |
|||
|
|||
// track other users
|
|||
this.socket.on('mousemove', (data) => { |
|||
// TODO@me check whether user is in view before rendering
|
|||
// TODO@me restrict rendering within range of zoom-level
|
|||
const { id, pos } = data |
|||
|
|||
// if user is not created, then do so
|
|||
if (!this.users[id]) { |
|||
const cursorIcon = L.icon({ iconUrl: '/icons/cursor.svg', iconSize: [ 20, 30 ], iconAnchor: [ 0, 8 ], className: 'user-cursor' }) |
|||
this.users[id] = L.marker([ pos.lat, pos.lng ], { icon: cursorIcon, interactive: false, zIndexOffset: 1000 }).addTo(this.leaflet) |
|||
} |
|||
|
|||
// update cursor's position
|
|||
this.users[id].setLatLng([ pos.lat, pos.lng ]) |
|||
}) |
|||
|
|||
// get the map with name
|
|||
this.mapController.get(this.name).then(m => { |
|||
this.socket.emit('connect-map', m.id) |
|||
|
|||
// set points
|
|||
this.setPoints(m.map_points) |
|||
}) |
|||
|
|||
} |
|||
|
|||
setPoints(points) { |
|||
points.forEach((p, i) => { |
|||
this.points[i] = L.marker([ p.location.x, p.location.y ]).addTo(this.leaflet) |
|||
this.points[i].bindPopup(`<h3>${p.name}</h3>${p.notes ? `<p>${p.notes}</p>` : ''}`) |
|||
}) |
|||
|
|||
const end = points.length - 1 |
|||
this.leaflet.setView([ points[end].location.x, points[end].location.y ], 13) |
|||
} |
|||
|
|||
render() { |
|||
return html`<main></main>` |
|||
} |
|||
|
|||
static get styles() { |
|||
return [ unsafeCSS(leafletCss), unsafeCSS(leafletContextCss), css` |
|||
:host { |
|||
display: flex; |
|||
flex-direction: column; |
|||
flex-grow: 1; |
|||
} |
|||
|
|||
main { |
|||
flex-grow: 1; |
|||
} |
|||
` ]
|
|||
} |
|||
} |
|||
|
|||
window.customElements.define('map-view', MapView) |
@ -0,0 +1,72 @@ |
|||
import { LitElement, html, css } from "lit"; |
|||
import Router from "../controllers/Router"; |
|||
|
|||
class NewMapModal extends LitElement { |
|||
static get properties() { |
|||
return { |
|||
mapName: { state: true } |
|||
} |
|||
} |
|||
|
|||
constructor() { |
|||
super() |
|||
this.mapName = '' |
|||
} |
|||
|
|||
navigate(evt) { |
|||
evt.preventDefault() |
|||
Router.navigate(`/m/${this.mapName}`) |
|||
} |
|||
|
|||
render() { |
|||
return html` |
|||
<main> |
|||
<div class="window"> |
|||
<form> |
|||
<label for="name">map name</label> |
|||
<input type="text" name="name" @input=${e => this.mapName = e.target.value}></input> |
|||
<div class="controls"> |
|||
<button @click=${this.navigate}>go</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</main>` |
|||
} |
|||
|
|||
static get styles() { |
|||
return css` |
|||
main { |
|||
position: absolute; |
|||
inset: 0; |
|||
display: grid; |
|||
align-content: center; |
|||
justify-items: center; |
|||
background: #33333350; |
|||
z-index: 1000; |
|||
} |
|||
|
|||
.window { |
|||
background: white; |
|||
padding: 1em; |
|||
border: thick solid black; |
|||
} |
|||
|
|||
.controls { |
|||
display: flex; |
|||
justify-content: end; |
|||
} |
|||
|
|||
button { |
|||
min-width: 5ch; |
|||
} |
|||
|
|||
form { |
|||
display: grid; |
|||
grid-auto-flow: rows; |
|||
gap: 0.25em; |
|||
} |
|||
` |
|||
} |
|||
} |
|||
|
|||
window.customElements.define('newmap-modal', NewMapModal) |
@ -0,0 +1,104 @@ |
|||
import { LitElement, html, css } from 'lit' |
|||
import { live } from 'lit/directives/live.js' |
|||
import PointController from '../controllers/PointController.js' |
|||
|
|||
class PointModal extends LitElement { |
|||
|
|||
pointController = new PointController(this) |
|||
|
|||
static get properties() { |
|||
return { |
|||
latlng: { type: Object }, |
|||
mapId: { type: Number, state: true }, |
|||
id: { type: Number }, |
|||
name: { type: String }, |
|||
notes: { type: String }, |
|||
modalEl: { state: true } |
|||
} |
|||
} |
|||
|
|||
constructor() { |
|||
super() |
|||
this.id = '' |
|||
this.mapId = '' |
|||
this.name = '' |
|||
this.notes = '' |
|||
this.latlng = {} |
|||
this.modalEl = {} |
|||
} |
|||
|
|||
firstUpdated() { |
|||
this.modalEl = this.shadowRoot.querySelector('dialog') |
|||
} |
|||
|
|||
open(mapId, latlng) { |
|||
this.mapId = mapId |
|||
console.log(mapId) |
|||
this.latlng = { ...latlng } |
|||
this.name = '' |
|||
this.notes = '' |
|||
this.modalEl.showModal() |
|||
} |
|||
|
|||
close(evt) { |
|||
evt.preventDefault() |
|||
this.modalEl.close() |
|||
} |
|||
|
|||
save(evt) { |
|||
evt.preventDefault() |
|||
console.log(this.mapId) |
|||
PointController.savePoint(this.mapId, { name: this.name, notes: this.notes, location: this.latlng }) |
|||
// TODO@me validate form entry
|
|||
// TODO@me check if successful
|
|||
this.close(evt) |
|||
} |
|||
|
|||
render() { |
|||
return html` |
|||
<dialog> |
|||
<h2>create new point</h2> |
|||
<p>${this.latlng.lat}, ${this.latlng.lng}</p> |
|||
<form> |
|||
<label name="name" for="name">name</label> |
|||
<input name="name" type="text" .value=${live(this.name)} @input=${e => this.name = e.target.value}> |
|||
|
|||
<label name="notes" for="notes">notes</label> |
|||
<textarea name="notes" .value=${live(this.notes)} @input=${e => this.notes = e.target.value}></textarea> |
|||
|
|||
<div class="controls"> |
|||
<button @click=${this.close}>cancel</button> <button @click=${this.save}>save</button> |
|||
</div> |
|||
</form> |
|||
</dialog> |
|||
` |
|||
} |
|||
|
|||
static get styles() { |
|||
return css` |
|||
form { |
|||
display: grid; |
|||
grid-auto-flow: rows; |
|||
} |
|||
|
|||
.controls { |
|||
display: flex; |
|||
} |
|||
|
|||
.controls > :first-child { |
|||
margin-inline-start: auto; |
|||
margin-inline-end: 0.25em; |
|||
} |
|||
|
|||
label, .controls { |
|||
margin-block-start: 0.25em; |
|||
} |
|||
|
|||
h2 { |
|||
margin-block: 0.125em; |
|||
} |
|||
` |
|||
} |
|||
} |
|||
|
|||
window.customElements.define('point-modal', PointModal) |
@ -0,0 +1,21 @@ |
|||
export default class MapController { |
|||
constructor(host) { |
|||
this.host = host |
|||
this.host.addController(this) |
|||
|
|||
this.map = {} |
|||
} |
|||
|
|||
async get(name) { |
|||
try { |
|||
const req = await fetch(`http://localhost:3000/api/map/${name}`) |
|||
const json = await req.json() |
|||
|
|||
this.map = json |
|||
|
|||
return json |
|||
} catch (err) { |
|||
console.error(err) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,28 @@ |
|||
export default class PointController { |
|||
constructor(host) { |
|||
this.host = host |
|||
this.host.addController(this) |
|||
} |
|||
|
|||
static async savePoint(mapId, point) { |
|||
try { |
|||
// set location to point for db
|
|||
point.location = `(${point.location.lat}, ${point.location.lng})` |
|||
|
|||
const res = await fetch('http://localhost:3000/api/point/add', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json' |
|||
}, |
|||
body: JSON.stringify({ |
|||
mapId, |
|||
point |
|||
}) |
|||
}) |
|||
|
|||
console.log(res) |
|||
} catch (err) { |
|||
console.error(err) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,36 @@ |
|||
import { html } from 'lit' |
|||
import { Router } from '@thepassle/app-tools/router.js' |
|||
|
|||
// views
|
|||
import '../components/MapView.js' |
|||
import '../components/NewMapModal.js' |
|||
|
|||
if (!globalThis.URLPattern) { |
|||
await import('urlpattern-polyfill') |
|||
} |
|||
|
|||
// router
|
|||
export default new Router({ |
|||
fallback: '/404', |
|||
routes: [ |
|||
{ |
|||
path: '/', |
|||
title: 'ethermap | index', |
|||
render: () => html` |
|||
<map-view></map-view> |
|||
<newmap-modal></newmap-modal> |
|||
` |
|||
}, |
|||
{ |
|||
path: '/m/:mapId', |
|||
title: ({ params }) => `ethermap | ${params.mapId}`, |
|||
render: ({ params }) => html`<map-view name=${params.mapId}></map-view>` |
|||
}, |
|||
{ |
|||
path: '/404', |
|||
title: 'etherpad | 404', |
|||
render: () => html`<h2>404 : page not found</h2>` |
|||
} |
|||
] |
|||
}) |
|||
|
@ -0,0 +1,9 @@ |
|||
import { defineConfig } from 'vite' |
|||
|
|||
export default defineConfig({ |
|||
base: '/', |
|||
build: { |
|||
sourcemap: true, |
|||
target: [ 'esnext', 'edge100', 'firefox100', 'chrome100', 'safari18' ] |
|||
} |
|||
}) |
Loading…
Reference in new issue