suroh
10 months 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