diff --git a/core/package-lock.json b/core/package-lock.json index bad7571..5f19ef6 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -28,6 +28,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/express-fileupload": "^1.4.4", + "@types/geojson": "^7946.0.14", "@types/jsonwebtoken": "^9.0.6", "@types/lru-cache": "^7.10.10", "@types/node": "^20.11.10", @@ -152,6 +153,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", diff --git a/core/package.json b/core/package.json index 81e6439..266c992 100644 --- a/core/package.json +++ b/core/package.json @@ -30,6 +30,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/express-fileupload": "^1.4.4", + "@types/geojson": "^7946.0.14", "@types/jsonwebtoken": "^9.0.6", "@types/lru-cache": "^7.10.10", "@types/node": "^20.11.10", diff --git a/core/src/index.ts b/core/src/index.ts index 064672d..5c3b6c9 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -14,6 +14,8 @@ import personRouter from './routes/person'; import organizationRouter from './routes/organization'; import roleRouter from './routes/role'; import authRouter from './routes/auth'; +import locationRouter from './routes/location'; +import nationRouter from './routes/nation'; dotenv.config(); @@ -38,6 +40,8 @@ app.use('/api/organ/', organRouter); app.use('/api/person/', personRouter); app.use('/api/organization/', organizationRouter); app.use('/api/role/', roleRouter); +app.use('/api/location/', locationRouter); +app.use('/api/nation/', nationRouter); app.use('/api/auth/', authRouter); app.get('/api/stats', async (req: Request, res: Response) => { diff --git a/core/src/middleware/nation.ts b/core/src/middleware/nation.ts index dfdc394..285b6b1 100644 --- a/core/src/middleware/nation.ts +++ b/core/src/middleware/nation.ts @@ -23,7 +23,7 @@ export const nid2nation = async (req: Request, res: Response, next: () => void): return; } const nation = await Nation.get(parseInt(req.params.nid)); - if (location === null) { + if (nation === null) { res.send({ 'success': false, 'msg': 'nation not found' }); return; } diff --git a/core/src/models/Location.ts b/core/src/models/Location.ts index 0bc705c..45f8341 100644 --- a/core/src/models/Location.ts +++ b/core/src/models/Location.ts @@ -13,12 +13,11 @@ const log = baseLogger('location'); class Location { public id: number; public name: string; - public coords?: [number, number]|null; + public coords?: GeoJSON.Point|null; - public constructor (id: number, name: string, coords?: [number, number]|null) { + public constructor (id: number, name: string, coords?: GeoJSON.Point|null) { this.id = id; this.name = name; - if (coords && coords.length !== 2) throw new Error('Location coordinates must be a tuple of two numbers.'); this.coords = coords; this.cache(); } @@ -43,8 +42,7 @@ class Location { return { id: this.id, name: this.name, - lat: this.coords?.at(0), - lng: this.coords?.at(1), + coords: this.coords, }; } @@ -61,7 +59,7 @@ class Location { * @returns A promise that resolves when the location has been updated. */ public async update (): Promise { - await pool.query('UPDATE location SET name = $1, coords = $2 WHERE lid = $3', [this.name, this.coords, this.id]); + await pool.query('UPDATE location SET name = $1, coords = st_geomfromgeojson($2) WHERE lid = $3', [this.name, this.coords, this.id]); log.debug(`Updated location ${this}`); this.cache(); } @@ -82,8 +80,8 @@ class Location { * @param coords The coordinates of the location. * @returns A promise that resolves with the new location. */ - public static async create (name: string, coords?: [number, number]): Promise { - const { rows } = await pool.query('INSERT INTO locations (name, coords) VALUES ($1, $2) RETURNING lid', [name, coords]); + public static async create (name: string, coords?: GeoJSON.Point): Promise { + const { rows } = await pool.query('INSERT INTO locations (name, coords) VALUES ($1, st_geomfromgeojson($2)) RETURNING lid', [name, coords]); return new Location(+rows[0].lid, name, coords); } @@ -96,10 +94,10 @@ class Location { id = +id; const cached = LOCATION_CACHE.get(id); if (cached === undefined || !(cached instanceof Location)) { - const { rows } = await pool.query('SELECT * FROM location WHERE lid = $1', [id,]); + const { rows } = await pool.query('SELECT lid, name, st_asgeojson(coords) "coords" FROM location WHERE lid = $1', [id,]); if (rows.length === 0) return null; const { name, coords } = rows[0]; - return new Location(+id, name, coords); + return new Location(+id, name, JSON.parse(coords)); } log.debug(`Hit location cache ${id}`); return (cached as Location) || null; @@ -111,8 +109,8 @@ class Location { * @returns A promise that resolves with the locations found. */ public static async find (query: string): Promise { - const { rows } = await pool.query('SELECT * FROM location WHERE name ILIKE $1', [`%${query}%`]); - return rows.map(({ lid, name, coords }) => new Location(+lid, name, coords)); + const { rows } = await pool.query('SELECT lid, name, st_asgeojson(coords) "coords" FROM location WHERE name ILIKE $1', [`%${query}%`]); + return rows.map(({ lid, name, coords }) => new Location(+lid, name, JSON.parse(coords))); } } diff --git a/core/src/models/Nation.ts b/core/src/models/Nation.ts index 85a1184..4541891 100644 --- a/core/src/models/Nation.ts +++ b/core/src/models/Nation.ts @@ -22,10 +22,13 @@ export interface NationCache extends OrganizationCache {} * Represents a political nation. */ class Nation extends Organization { + public geo?: GeoJSON.Polygon|null; + protected _cache: NationCache = {}; - public constructor (id: number, bio: string, name: string, established?: Date|null, dissolved?: Date|null, location?: Location|null) { + public constructor (id: number, bio: string, name: string, established?: Date|null, dissolved?: Date|null, location?: Location|null, geo?: GeoJSON.Polygon|null) { super(id, bio, name, established, dissolved, location); + this.geo = geo; this.cache(); } @@ -51,7 +54,7 @@ class Nation extends Organization { public json (): Object { return { ...super.json(), - location: this.location?.json(), + geo: this.geo, }; } @@ -110,19 +113,20 @@ class Nation extends Organization { if (cached === undefined || !(cached instanceof Nation)) { const client = await pool.connect(); const result = await client.query( - 'SELECT * FROM nation WHERE nid = $1', + 'SELECT st_asgeojson(geo) "geo" FROM nation WHERE nid = $1', [id] ); client.release(); if (result.rowCount === 0) return null; - const location = result.rows[0].location; + const geo = result.rows[0].geo; return new Nation( id, organization.bio, organization.name, organization.established, organization.dissolved, - location ? await Location.get(location) : undefined + organization.location, + geo ? JSON.parse(geo) : null ); } log.debug(`Hit nation cache ${id}`); diff --git a/db/define.sql b/db/define.sql index 9f54c49..3705da3 100644 --- a/db/define.sql +++ b/db/define.sql @@ -56,7 +56,7 @@ CREATE TABLE tag ( CREATE TABLE location ( lid BIGSERIAL, name VARCHAR(256) NOT NULL, - coords POINT, + coords GEOGRAPHY(POINT), PRIMARY KEY (lid) ); @@ -73,6 +73,7 @@ CREATE TABLE organization ( CREATE TABLE nation ( nid BIGINT REFERENCES organization(oid), + geo GEOGRAPHY(POLYGON), PRIMARY KEY (nid) ); diff --git a/web/package-lock.json b/web/package-lock.json index ef6f272..26f975e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,6 +17,7 @@ "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.5", + "@vue-leaflet/vue-leaflet": "^0.10.1", "@vue/apollo-composable": "^4.0.1", "@vueuse/core": "^10.7.2", "animejs": "^3.2.2", @@ -33,6 +34,7 @@ "dompurify": "^3.1.2", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", + "leaflet": "^1.9.4", "marked": "^12.0.2", "marked-alert": "^2.0.1", "marked-linkify-it": "^3.1.9", @@ -2882,6 +2884,23 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@vue-leaflet/vue-leaflet": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@vue-leaflet/vue-leaflet/-/vue-leaflet-0.10.1.tgz", + "integrity": "sha512-RNEDk8TbnwrJl8ujdbKgZRFygLCxd0aBcWLQ05q/pGv4+d0jamE3KXQgQBqGAteE1mbQsk3xoNcqqUgaIGfWVg==", + "dependencies": { + "vue": "^3.2.25" + }, + "peerDependencies": { + "@types/leaflet": "^1.5.7", + "leaflet": "^1.6.0" + }, + "peerDependenciesMeta": { + "@types/leaflet": { + "optional": true + } + } + }, "node_modules/@vue/apollo-composable": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@vue/apollo-composable/-/apollo-composable-4.0.1.tgz", @@ -5163,6 +5182,11 @@ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/web/package.json b/web/package.json index e321d5d..88e9752 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.5", + "@vue-leaflet/vue-leaflet": "^0.10.1", "@vue/apollo-composable": "^4.0.1", "@vueuse/core": "^10.7.2", "animejs": "^3.2.2", @@ -37,6 +38,7 @@ "dompurify": "^3.1.2", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", + "leaflet": "^1.9.4", "marked": "^12.0.2", "marked-alert": "^2.0.1", "marked-linkify-it": "^3.1.9",