Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
@@ -0,0 +1,39 @@
|
||||
# vite-project
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "maplibre_map_poc",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@indoorequal/vue-maplibre-gl": "8.4.1",
|
||||
"maplibre-gl": "5.6.2",
|
||||
"pinia": "3.0.3",
|
||||
"vue": "3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prettier/plugin-oxc": "0.0.4",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/maplibre-gl": "^1.14.0",
|
||||
"@types/node": "24.2.1",
|
||||
"@vitejs/plugin-vue": "6.0.1",
|
||||
"@vue/eslint-config-prettier": "10.2.0",
|
||||
"@vue/eslint-config-typescript": "14.6.0",
|
||||
"@vue/tsconfig": "0.7.0",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-plugin-oxlint": "1.11.1",
|
||||
"eslint-plugin-vue": "10.4.0",
|
||||
"jiti": "2.5.1",
|
||||
"npm-run-all2": "8.0.4",
|
||||
"oxlint": "1.11.1",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "5.9.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-vue-devtools": "8.0.0",
|
||||
"vue-tsc": "3.0.5"
|
||||
}
|
||||
}
|
||||
Generated
+3614
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import MapView from "./views/MapView.vue"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MapView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Fira Sans",
|
||||
"Droid Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,54 @@
|
||||
@import "./base.css";
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
} */
|
||||
|
||||
/* #app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
} */
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="17"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, type Ref } from "vue"
|
||||
import {
|
||||
MglMap,
|
||||
MglNavigationControl,
|
||||
MglMarker,
|
||||
MglPopup,
|
||||
MglGeoJsonSource,
|
||||
MglImage,
|
||||
MglSymbolLayer,
|
||||
MglCircleLayer,
|
||||
MglLineLayer,
|
||||
type MglEvent,
|
||||
} from "@indoorequal/vue-maplibre-gl"
|
||||
import { type LngLatLike, LngLat, type MapLayerMouseEvent } from "maplibre-gl"
|
||||
import { useMeters } from "@/composables/useMeters"
|
||||
|
||||
const style = "https://tiles.openfreemap.org/styles/liberty"
|
||||
// Center the map on Saudi Arabia (Riyadh coordinates)
|
||||
const markerCoordinates: Ref<LngLat> = ref(new LngLat(46.6753, 24.7136))
|
||||
const center: Ref<LngLatLike> = ref([46.6753, 24.7136]) // Riyadh, Saudi Arabia
|
||||
const zoom: Ref<number> = ref(6) // Zoom level to show most of Saudi Arabia
|
||||
|
||||
// Use the meters composable
|
||||
const {
|
||||
metersData,
|
||||
isLoading: metersLoading,
|
||||
error: metersError,
|
||||
fetchMeters,
|
||||
currentMeter,
|
||||
} = useMeters()
|
||||
|
||||
function getLocation() {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
markerCoordinates.value.lng = position.coords.longitude
|
||||
markerCoordinates.value.lat = position.coords.latitude
|
||||
// Update the map center with the current location
|
||||
center.value = [
|
||||
markerCoordinates.value.lng,
|
||||
markerCoordinates.value.lat,
|
||||
]
|
||||
zoom.value = 12
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error getting location:", error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnSaudiArabia() {
|
||||
// Center on Riyadh and zoom out to show most of Saudi Arabia
|
||||
center.value = [46.6753, 24.7136]
|
||||
zoom.value = 6
|
||||
markerCoordinates.value = new LngLat(46.6753, 24.7136)
|
||||
}
|
||||
|
||||
function handleMeterClick(event: MapLayerMouseEvent) {
|
||||
console.log("Meter clicked at:", event.features)
|
||||
// Get the clicked meter feature
|
||||
// const clickedMeter = event.features
|
||||
// console.log("Clicked meter:", clickedMeter)
|
||||
// if (clickedMeter) {
|
||||
// currentMeter.value = clickedMeter
|
||||
// }
|
||||
}
|
||||
|
||||
function handleClusterClick(event: MapLayerMouseEvent) {
|
||||
console.log("Cluster clicked at:", event.features)
|
||||
// Get the clicked meter feature
|
||||
// const clickedMeter = event.features
|
||||
// console.log("Clicked meter:", clickedMeter)
|
||||
// if (clickedMeter) {
|
||||
// currentMeter.value = clickedMeter
|
||||
// }
|
||||
}
|
||||
|
||||
function handleMeterMouseEnter(event: MapLayerMouseEvent) {
|
||||
event.target.getCanvas().style.cursor = "pointer"
|
||||
}
|
||||
|
||||
function handleMeterMouseLeave(event: MapLayerMouseEvent) {
|
||||
event.target.getCanvas().style.cursor = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- CURRENT LOCATION BUTTON -->
|
||||
<button style="margin-bottom: 1rem" @click="getLocation">Get Location</button>
|
||||
<!-- CENTER ON SAUDI ARABIA BUTTON -->
|
||||
<button
|
||||
style="margin-bottom: 1rem; margin-left: 1rem"
|
||||
@click="centerOnSaudiArabia"
|
||||
>
|
||||
🇸🇦 Center on Saudi Arabia
|
||||
</button>
|
||||
<br />
|
||||
<code>
|
||||
Latitude: {{ markerCoordinates.lat }} | Longitude:
|
||||
{{ markerCoordinates.lng }}
|
||||
</code>
|
||||
<!-- METER DATA BUTTON -->
|
||||
<button style="margin-bottom: 1rem" @click="fetchMeters">
|
||||
{{ metersLoading ? "Loading..." : "Get Meters" }}
|
||||
</button>
|
||||
<div v-if="metersError" style="color: red; margin-bottom: 1rem">
|
||||
Error: {{ metersError }}
|
||||
</div>
|
||||
<!-- MAP -->
|
||||
<div id="mapContainer">
|
||||
<mgl-map
|
||||
:map-style="style"
|
||||
:center="center"
|
||||
v-model:zoom="zoom"
|
||||
height="500px"
|
||||
>
|
||||
<mgl-navigation-control />
|
||||
<!-- DRAGGABLE MARKER -->
|
||||
<mgl-marker
|
||||
v-model:coordinates="markerCoordinates"
|
||||
:anchor="'center'"
|
||||
:draggable="true"
|
||||
>
|
||||
<!-- POPUP -->
|
||||
<mgl-popup>
|
||||
<h3>Current Location</h3>
|
||||
<p>Latitude: {{ markerCoordinates.lat }}</p>
|
||||
<p>Longitude: {{ markerCoordinates.lng }}</p>
|
||||
</mgl-popup>
|
||||
</mgl-marker>
|
||||
<!-- IMAGE -->
|
||||
<mgl-image
|
||||
id="meter"
|
||||
url="https://upload.wikimedia.org/wikipedia/commons/7/7c/201408_cat.png"
|
||||
/>
|
||||
<!-- GEOJSON SOURCE -->
|
||||
<mgl-geo-json-source
|
||||
source-id="meters"
|
||||
:data="metersData"
|
||||
:cluster="true"
|
||||
>
|
||||
<!-- UNCLUSTER LAYER -->
|
||||
<mgl-symbol-layer
|
||||
layer-id="meter"
|
||||
:filter="['!=', 'cluster', true]"
|
||||
:layout="{
|
||||
'icon-image': 'meter',
|
||||
'icon-size': 0.1,
|
||||
'icon-allow-overlap': true,
|
||||
'symbol-sort-key': 1,
|
||||
}"
|
||||
@click="handleMeterClick"
|
||||
@mouseleave="handleMeterMouseLeave"
|
||||
@mouseenter="handleMeterMouseEnter"
|
||||
/>
|
||||
<!-- CLUSTER LAYER -->
|
||||
|
||||
<mgl-circle-layer
|
||||
layer-id="meter-cluster"
|
||||
:filter="['==', 'cluster', true]"
|
||||
@click="handleClusterClick"
|
||||
:paint="{
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#51bbd6',
|
||||
100,
|
||||
'#f1f075',
|
||||
400,
|
||||
'#f28cb1',
|
||||
],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20,
|
||||
100,
|
||||
30,
|
||||
400,
|
||||
40,
|
||||
],
|
||||
}"
|
||||
/>
|
||||
<!-- <mgl-symbol-layer
|
||||
layer-id="meter-cluster-label"
|
||||
:filter="['==', 'cluster', true]"
|
||||
:layout="{
|
||||
'text-field': '59',
|
||||
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
||||
'text-size': 12,
|
||||
}"
|
||||
/> -->
|
||||
<!-- <mgl-line-layer
|
||||
layer-id="saudi_contour"
|
||||
:layout="{
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
}"
|
||||
:paint="{
|
||||
'line-color': '#FF0000',
|
||||
'line-width': 1,
|
||||
}"
|
||||
/> -->
|
||||
</mgl-geo-json-source>
|
||||
</mgl-map>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
@import "maplibre-gl/dist/maplibre-gl.css";
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div ref="mapContainer" class="map-container"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch, useTemplateRef } from "vue"
|
||||
import "maplibre-gl/dist/maplibre-gl.css"
|
||||
import {
|
||||
GeoJSONSource,
|
||||
Map,
|
||||
Popup,
|
||||
type LngLatLike,
|
||||
type MapLayerMouseEvent,
|
||||
} from "maplibre-gl"
|
||||
|
||||
// Note: You will need to install MapLibre GL JS:
|
||||
// npm install maplibre-gl
|
||||
|
||||
const props = defineProps({
|
||||
geojson: {
|
||||
type: Object as () => GeoJSON.FeatureCollection,
|
||||
required: false,
|
||||
},
|
||||
center: {
|
||||
type: Array as unknown as () => LngLatLike,
|
||||
default: () => [46.6753, 24.7136], // Default to Riyadh, Saudi Arabia
|
||||
},
|
||||
})
|
||||
|
||||
// Emit events to support v-model:center from parent components.
|
||||
const emit = defineEmits<{
|
||||
"update:center": [value: LngLatLike]
|
||||
}>()
|
||||
|
||||
const mapContainer = useTemplateRef("mapContainer")
|
||||
let map: Map | null = null
|
||||
|
||||
// A simple function to generate a large dummy GeoJSON dataset in Saudi Arabia for demonstration.
|
||||
// In a real application, this data would be passed in via props, likely streamed
|
||||
// from a server.
|
||||
const generateMockData = (count: number): GeoJSON.FeatureCollection => {
|
||||
const features: GeoJSON.Feature[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
features.push({
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [
|
||||
46.6034 + (Math.random() - 0.5) * 0.5, // Longitude
|
||||
24.7136 + (Math.random() - 0.5) * 0.5, // Latitude
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
id: `meter-${i}`,
|
||||
type: Math.random() > 0.5 ? "water" : "electricity",
|
||||
},
|
||||
})
|
||||
}
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
// Use the mock data for demonstration if no geojson is passed.
|
||||
// In a real app, this line would be removed, and props.geojson would be used directly.
|
||||
const mapData: GeoJSON.FeatureCollection =
|
||||
props.geojson || generateMockData(100000)
|
||||
|
||||
const addLayersAndSources = () => {
|
||||
if (!map) return
|
||||
if (map.getSource("meters")) {
|
||||
map.removeLayer("clusters")
|
||||
map.removeLayer("cluster-count")
|
||||
map.removeLayer("unclustered-point")
|
||||
map.removeSource("meters")
|
||||
}
|
||||
|
||||
// Add a new source from the GeoJSON data with clustering enabled.
|
||||
map.addSource("meters", {
|
||||
type: "geojson",
|
||||
data: mapData,
|
||||
cluster: true,
|
||||
clusterMaxZoom: 14, // Max zoom to cluster points on
|
||||
clusterRadius: 50, // Radius of each cluster in pixels
|
||||
})
|
||||
|
||||
// Add the 'clusters' layer for the cluster circles.
|
||||
map.addLayer({
|
||||
id: "clusters",
|
||||
type: "circle",
|
||||
source: "meters",
|
||||
filter: ["has", "point_count"],
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
"#51bbd6",
|
||||
100,
|
||||
"#d350c2",
|
||||
750,
|
||||
"#f28cb1",
|
||||
],
|
||||
"circle-radius": ["step", ["get", "point_count"], 20, 100, 30, 750, 40],
|
||||
"circle-stroke-color": "#fff",
|
||||
"circle-stroke-width": 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Add a layer for the number of points in a cluster.
|
||||
map.addLayer({
|
||||
id: "cluster-count",
|
||||
type: "symbol",
|
||||
source: "meters",
|
||||
filter: ["has", "point_count"],
|
||||
layout: {
|
||||
"text-field": "{point_count_abbreviated}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 12,
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#fff",
|
||||
},
|
||||
})
|
||||
|
||||
// Add a layer for the individual unclustered points.
|
||||
map.addLayer({
|
||||
id: "unclustered-point",
|
||||
type: "symbol",
|
||||
source: "meters",
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
layout: {
|
||||
// Use an SVG to create a custom icon. This is more flexible than an image.
|
||||
// You can generate SVGs for different meter types (water vs. electricity).
|
||||
"icon-image": "meter-icon", // The ID of the icon you added to the map.
|
||||
"icon-size": 0.8,
|
||||
"icon-allow-overlap": true,
|
||||
},
|
||||
})
|
||||
|
||||
// Add a click listener to the clusters to zoom in when clicked.
|
||||
map.on("click", "clusters", handleClusterClick)
|
||||
|
||||
// Add a click listener to the unclustered points.
|
||||
map.on("click", "unclustered-point", handleMeterClick)
|
||||
|
||||
// Add mouse enter/leave listeners for the unclustered points.
|
||||
map.on("mouseenter", "unclustered-point", handleMeterMouseEnter)
|
||||
map.on("mouseleave", "unclustered-point", handleMeterMouseLeave)
|
||||
|
||||
// Add mouse enter/leave listeners for the clusters.
|
||||
map.on("mouseenter", "clusters", handleMeterMouseEnter)
|
||||
map.on("mouseleave", "clusters", handleMeterMouseLeave)
|
||||
}
|
||||
|
||||
function handleMeterClick(event: MapLayerMouseEvent) {
|
||||
if (!map) return
|
||||
console.log("Meter clicked at:", event.features)
|
||||
// Get the clicked meter feature
|
||||
const clickedMeter = event.features?.[0]
|
||||
// console.log("Clicked meter:", typeof clickedMeter?.geometry)
|
||||
// Shows popup with meter details
|
||||
if (clickedMeter && clickedMeter.geometry.type == "Point") {
|
||||
new Popup()
|
||||
.setLngLat(clickedMeter.geometry.coordinates as LngLatLike)
|
||||
.setHTML(
|
||||
`Meter ID: ${clickedMeter.properties.id}<br>Type: ${clickedMeter.properties.type}`
|
||||
)
|
||||
.addTo(map)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClusterClick(event: MapLayerMouseEvent) {
|
||||
console.log("Cluster clicked at:", event)
|
||||
// Get the clicked meter feature
|
||||
// const clickedMeter = event.features
|
||||
// console.log("Clicked meter:", clickedMeter)
|
||||
// if (clickedMeter) {
|
||||
// currentMeter.value = clickedMeter
|
||||
// }
|
||||
}
|
||||
|
||||
function handleMeterMouseEnter(event: MapLayerMouseEvent) {
|
||||
event.target.getCanvas().style.cursor = "pointer"
|
||||
}
|
||||
|
||||
function handleMeterMouseLeave(event: MapLayerMouseEvent) {
|
||||
event.target.getCanvas().style.cursor = ""
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize the map on component mount.
|
||||
map = new Map({
|
||||
container: mapContainer.value as HTMLElement,
|
||||
style: "https://tiles.openfreemap.org/styles/liberty", // Use a basic map style.
|
||||
center: props.center,
|
||||
zoom: 9,
|
||||
})
|
||||
|
||||
// Load the SVG icon for the meter before adding the layers.
|
||||
// Using an inline SVG is a great way to avoid external image requests.
|
||||
// electricity meter
|
||||
const electricityMeterIconSVG = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#007bff" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||
<path d="M14.5 14.5l-3.25-3.25L9.5 13.5l1.75 1.75z"/>
|
||||
<path d="M12 8V6m0 12v-2m-6-6H6m12 0h-2"/>
|
||||
</svg>`
|
||||
|
||||
// water meter
|
||||
// const waterMeterIconSVG = `
|
||||
// <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#007bff" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
// <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||
// <path d="M14.5 14.5l-3.25-3.25L9.5 13.5l1.75 1.75z"/>
|
||||
// <path d="M12 8V6m0 12v-2m-6-6H6m12 0h-2"/>
|
||||
// </svg>`
|
||||
|
||||
const blob = new Blob([electricityMeterIconSVG], { type: "image/svg+xml" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
|
||||
image.onload = () => {
|
||||
if (!map?.hasImage("meter-icon")) {
|
||||
map?.addImage("meter-icon", image)
|
||||
}
|
||||
|
||||
// Wait for the map to load before adding the source and layers.
|
||||
map?.on("load", () => {
|
||||
addLayersAndSources()
|
||||
|
||||
// Emit center updates to support v-model:center when the map finishes moving.
|
||||
map?.on("moveend", () => {
|
||||
if (!map) return
|
||||
const centerArray = map.getCenter().toArray() as LngLatLike
|
||||
emit("update:center", centerArray)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up the map instance to prevent memory leaks.
|
||||
if (map) {
|
||||
map.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch the geojson prop and update the map when the data changes.
|
||||
watch(
|
||||
() => props.geojson,
|
||||
(newGeojson) => {
|
||||
if (map && map.isSourceLoaded("meters") && newGeojson) {
|
||||
const source = map.getSource("meters") as GeoJSONSource
|
||||
if (source) {
|
||||
source.setData(newGeojson)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Watch the incoming center prop so the parent can control the map center via v-model:center
|
||||
watch(
|
||||
() => props.center,
|
||||
(newCenter) => {
|
||||
if (!map || !newCenter) return
|
||||
const current = map.getCenter().toArray()
|
||||
// Only update if the incoming center is an array and actually different to avoid feedback loops
|
||||
if (Array.isArray(newCenter) && newCenter.length >= 2) {
|
||||
if (newCenter[0] !== current[0] || newCenter[1] !== current[1]) {
|
||||
map.setCenter(newCenter as [number, number])
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 500px; /* Set a default height, or adjust as needed */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ref, type Ref } from "vue"
|
||||
import { type MetersCollection, type MeterFeature } from "shared-types/meters"
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:3001/api"
|
||||
|
||||
/**
|
||||
* Composable for managing meter data from the backend API
|
||||
*/
|
||||
export function useMeters() {
|
||||
const metersData: Ref<MetersCollection> = ref({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const currentMeter: Ref<MeterFeature | null> = ref(null)
|
||||
|
||||
/**
|
||||
* Create the meter image from an svg
|
||||
*/
|
||||
const DEFAULT_SVG =
|
||||
'<svg width="100px" height="100px" viewBox="-6.4 -6.4 76.80 76.80" data-name="Layer 1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" fill="#000000" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-000000, #000000);"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><defs><style>.cls-1{fill:#838bc5;}.cls-2{fill:#65c8d0;}.cls-3{fill:#8f6c56;}.cls-4{fill:#f4ecce;}.cls-5{fill:#ba9bc9;}.cls-6{fill:#fcdd7c;}</style><style class="darkreader darkreader--sync" media="screen"></style></defs><polyline class="cls-1" points="55 44 55 62 9 62 9 44"></polyline><path class="cls-2" d="M30,44V56a2,2,0,0,1-2,2h0a2,2,0,0,1-2-2V44"></path><path class="cls-2" d="M22,44V56a2,2,0,0,1-2,2h0a2,2,0,0,1-2-2V44"></path><path class="cls-2" d="M46,44V56a2,2,0,0,1-2,2h0a2,2,0,0,1-2-2V44"></path><path class="cls-2" d="M38,44V56a2,2,0,0,1-2,2h0a2,2,0,0,1-2-2V44"></path><rect class="cls-3" height="42" rx="2" width="56" x="4" y="2"></rect><rect class="cls-1" height="34" width="48" x="8" y="6"></rect><rect class="cls-4" height="8" width="40" x="12" y="19"></rect><path class="cls-5" d="M52,18H12a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1H52a1,1,0,0,0,1-1V19A1,1,0,0,0,52,18ZM29,20h6v6H29Zm-2,6H21V20h6Zm10-6h6v6H37ZM13,20h6v6H13Zm38,6H45V20h6Z"></path><rect class="cls-2" height="4" width="2" x="15" y="21"></rect><rect class="cls-2" height="4" width="2" x="23" y="21"></rect><rect class="cls-2" height="4" width="2" x="31" y="21"></rect><rect class="cls-2" height="4" width="2" x="39" y="21"></rect><rect class="cls-2" height="4" width="2" x="47" y="21"></rect><rect class="cls-4" height="2" width="6" x="11" y="9"></rect><rect class="cls-4" height="2" width="6" x="19" y="9"></rect><rect class="cls-4" height="2" width="6" x="27" y="9"></rect><rect class="cls-4" height="2" width="6" x="35" y="9"></rect><rect class="cls-2" height="4" width="8" x="44" y="10"></rect><rect class="cls-6" height="2" width="2" x="13" y="13"></rect><rect class="cls-6" height="2" width="2" x="21" y="13"></rect><rect class="cls-6" height="2" width="2" x="29" y="13"></rect><rect class="cls-6" height="2" width="2" x="37" y="13"></rect><circle class="cls-2" cx="14" cy="34" r="2"></circle><circle class="cls-2" cx="22" cy="34" r="2"></circle><circle class="cls-2" cx="30" cy="34" r="2"></circle><circle class="cls-2" cx="38" cy="34" r="2"></circle><path class="cls-5" d="M14,37a3,3,0,1,1,3-3A3,3,0,0,1,14,37Zm0-4a1,1,0,1,0,1,1A1,1,0,0,0,14,33Z"></path><path class="cls-5" d="M22,37a3,3,0,1,1,3-3A3,3,0,0,1,22,37Zm0-4a1,1,0,1,0,1,1A1,1,0,0,0,22,33Z"></path><path class="cls-5" d="M30,37a3,3,0,1,1,3-3A3,3,0,0,1,30,37Zm0-4a1,1,0,1,0,1,1A1,1,0,0,0,30,33Z"></path><path class="cls-5" d="M38,37a3,3,0,1,1,3-3A3,3,0,0,1,38,37Zm0-4a1,1,0,1,0,1,1A1,1,0,0,0,38,33Z"></path><path class="cls-6" d="M52,37.32l-1.9-.64L50.61,35H49a1,1,0,0,1-.81-.42,1,1,0,0,1-.14-.9l1-3,1.9.64L50.39,33H52a1,1,0,0,1,.81.42,1,1,0,0,1,.14.9Z"></path><rect class="cls-6" height="4" rx="2" width="36" x="14" y="48"></rect></g></svg>'
|
||||
function createMeterImage(svg: string = DEFAULT_SVG): HTMLImageElement {
|
||||
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
img.style.display = "none"
|
||||
return img
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all meters from the API
|
||||
*/
|
||||
async function fetchMeters(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/meters`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data: MetersCollection = await response.json()
|
||||
metersData.value = data
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : "Failed to fetch meters"
|
||||
console.error("Error fetching meters:", err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific meter by ID
|
||||
*/
|
||||
async function fetchMeterById(id: string): Promise<MeterFeature | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/meters/${id}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data: MeterFeature = await response.json()
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to fetch meter"
|
||||
console.error("Error fetching meter:", err)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metersData,
|
||||
currentMeter,
|
||||
isLoading,
|
||||
error,
|
||||
fetchMeters,
|
||||
fetchMeterById,
|
||||
createMeterImage,
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import "./assets/main.css";
|
||||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useCounterStore = defineStore("counter", () => {
|
||||
const count = ref(0);
|
||||
const doubleCount = computed(() => count.value * 2);
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment };
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<!-- CURRENT LOCATION BUTTON -->
|
||||
<button style="margin-bottom: 1rem" @click="getLocation">Get Location</button>
|
||||
<!-- CENTER ON SAUDI ARABIA BUTTON -->
|
||||
<button
|
||||
style="margin-bottom: 1rem; margin-left: 1rem"
|
||||
@click="centerOnSaudiArabia"
|
||||
>
|
||||
🇸🇦 Center on Saudi Arabia
|
||||
</button>
|
||||
<!-- CURRENT CENTER LOCATION DISPLAY -->
|
||||
<div>map is centered at {{ `LATITUDE: ${(center as Array<number>)[1]}, LONGITUDE: ${(center as Array<number>)[0]}` }}</div>
|
||||
<!-- RAW MAP LIBRE COMPONENT -->
|
||||
<RawMapLibre v-model:center="center" :geojson="metersData" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, type Ref } from "vue"
|
||||
import RawMapLibre from "@/components/map/RawMapLibre.vue"
|
||||
import { useMeters } from "@/composables/useMeters"
|
||||
import { type LngLatLike, LngLat } from "maplibre-gl"
|
||||
|
||||
// Center the map on Saudi Arabia (Riyadh coordinates)
|
||||
const markerCoordinates = ref(new LngLat(46.6753, 24.7136))
|
||||
const center: Ref<LngLatLike> = ref(markerCoordinates.value.toArray())
|
||||
const zoom = ref(8) // Default zoom level
|
||||
|
||||
// Use the meters composable
|
||||
const {
|
||||
metersData,
|
||||
isLoading: metersLoading,
|
||||
error: metersError,
|
||||
fetchMeters,
|
||||
currentMeter,
|
||||
} = useMeters()
|
||||
|
||||
function getLocation() {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
markerCoordinates.value.lng = position.coords.longitude
|
||||
markerCoordinates.value.lat = position.coords.latitude
|
||||
// Update the map center with the current location
|
||||
center.value = [
|
||||
markerCoordinates.value.lng,
|
||||
markerCoordinates.value.lat,
|
||||
]
|
||||
zoom.value = 12
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error getting location:", error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnSaudiArabia() {
|
||||
// Center on Riyadh and zoom out to show most of Saudi Arabia
|
||||
markerCoordinates.value = new LngLat(46.6753, 24.7136)
|
||||
center.value = markerCoordinates.value.toArray()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Fetch meters data when the component is mounted
|
||||
fetchMeters()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
# Test Backend - Saudi Meters API
|
||||
|
||||
A simple Express.js server that serves the Saudi meters GeoJSON data.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production Mode
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /` - API information and available endpoints
|
||||
- `GET /api/meters` - Get all meters data (GeoJSON FeatureCollection)
|
||||
- `GET /api/meters/:id` - Get specific meter by ID
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Get all meters
|
||||
curl http://localhost:3001/api/meters
|
||||
|
||||
# Get specific meter
|
||||
curl http://localhost:3001/api/meters/meter-757
|
||||
|
||||
# Health check
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
The server runs on port 3001 by default, but you can override it with the `PORT` environment variable.
|
||||
@@ -0,0 +1,80 @@
|
||||
import express, { Express } from "express"
|
||||
import cors from "cors"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { fileURLToPath } from "url"
|
||||
import { dirname } from "path"
|
||||
import { MetersCollection, MeterFeature } from "shared-types/meters"
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const app: Express = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
// Middleware
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// Route to serve the saudi_meters.json file
|
||||
app.get("/api/meters", (req, res) => {
|
||||
try {
|
||||
const filePath = path.join(__dirname, "saudi_meters.json")
|
||||
const data = fs.readFileSync(filePath, "utf8")
|
||||
const metersData: MetersCollection = JSON.parse(data)
|
||||
|
||||
res.json(metersData)
|
||||
} catch (error) {
|
||||
console.error("Error reading meters data:", error)
|
||||
res.status(500).json({ error: "Failed to load meters data" })
|
||||
}
|
||||
})
|
||||
|
||||
// Route to get individual meter by ID
|
||||
app.get("/api/meters/:id", (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const filePath = path.join(__dirname, "saudi_meters.json")
|
||||
const data = fs.readFileSync(filePath, "utf8")
|
||||
const metersData: MetersCollection = JSON.parse(data)
|
||||
|
||||
const meter = metersData.features.find(
|
||||
(feature: MeterFeature) => feature.properties.id === id
|
||||
)
|
||||
|
||||
if (meter) {
|
||||
res.json(meter)
|
||||
} else {
|
||||
res.status(404).json({ error: "Meter not found" })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading meters data:", error)
|
||||
res.status(500).json({ error: "Failed to load meters data" })
|
||||
}
|
||||
})
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "OK", timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
// Basic info endpoint
|
||||
app.get("/", (req, res) => {
|
||||
res.json({
|
||||
message: "Saudi Meters API Server",
|
||||
endpoints: {
|
||||
meters: "/api/meters",
|
||||
meterById: "/api/meters/:id",
|
||||
health: "/health",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Start the server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on http://localhost:${PORT}`)
|
||||
console.log(`📊 Meters data available at http://localhost:${PORT}/api/meters`)
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,266 @@
|
||||
import fs from "fs"
|
||||
|
||||
// Load the actual Saudi Arabia borders from sa.json
|
||||
const saudiPolygon = JSON.parse(fs.readFileSync("sa.json", "utf8"))
|
||||
|
||||
// Saudi Arabia accurate geographical bounds (for quick bounding box checks)
|
||||
// More precise boundaries to avoid points in neighboring countries
|
||||
const SAUDI_BOUNDS = {
|
||||
minLng: 36.0, // More conservative western boundary (Red Sea coast)
|
||||
maxLng: 55.0, // More conservative eastern boundary (Persian Gulf coast)
|
||||
minLat: 17.0, // More conservative southern boundary (Yemen border)
|
||||
maxLat: 32.0, // Northern boundary (Jordan/Iraq border)
|
||||
}
|
||||
|
||||
// Point-in-polygon algorithm (ray casting)
|
||||
function pointInPolygon(point, polygon) {
|
||||
const [x, y] = point
|
||||
let inside = false
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const [xi, yi] = polygon[i]
|
||||
const [xj, yj] = polygon[j]
|
||||
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
|
||||
return inside
|
||||
}
|
||||
|
||||
// Check if point is inside any of the Saudi polygons
|
||||
function isPointInSaudiArabia(point) {
|
||||
const multiPolygon = saudiPolygon.features[0].geometry.coordinates
|
||||
|
||||
for (const polygon of multiPolygon) {
|
||||
// Each polygon might have holes, first ring is exterior, others are holes
|
||||
const exteriorRing = polygon[0]
|
||||
|
||||
if (pointInPolygon(point, exteriorRing)) {
|
||||
// Check if point is in any holes
|
||||
let inHole = false
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
if (pointInPolygon(point, polygon[i])) {
|
||||
inHole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!inHole) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Major Saudi cities for reference and better distribution (verified coordinates)
|
||||
const MAJOR_CITIES = [
|
||||
{ name: "Riyadh", lng: 46.6753, lat: 24.7136 },
|
||||
{ name: "Jeddah", lng: 39.2376, lat: 21.4858 },
|
||||
{ name: "Mecca", lng: 39.8579, lat: 21.3891 },
|
||||
{ name: "Medina", lng: 39.6118, lat: 24.5247 },
|
||||
{ name: "Dammam", lng: 50.088, lat: 26.4207 },
|
||||
{ name: "Khobar", lng: 50.208, lat: 26.2791 },
|
||||
{ name: "Tabuk", lng: 36.5951, lat: 28.3998 },
|
||||
{ name: "Buraidah", lng: 43.975, lat: 26.326 },
|
||||
{ name: "Khamis Mushait", lng: 42.7284, lat: 18.3057 },
|
||||
{ name: "Hail", lng: 41.69, lat: 27.5114 },
|
||||
{ name: "Hofuf", lng: 49.586, lat: 25.3491 },
|
||||
{ name: "Jubail", lng: 49.6603, lat: 27.0174 },
|
||||
{ name: "Abha", lng: 42.5058, lat: 18.2164 },
|
||||
{ name: "Yanbu", lng: 38.0618, lat: 24.0895 },
|
||||
{ name: "Najran", lng: 44.13, lat: 17.4924 },
|
||||
{ name: "Arar", lng: 41.0377, lat: 30.9753 },
|
||||
{ name: "Al Qatif", lng: 50.0089, lat: 26.5056 },
|
||||
{ name: "Sakaka", lng: 40.2062, lat: 29.9697 },
|
||||
{ name: "Al Bahah", lng: 41.4687, lat: 20.0129 },
|
||||
].filter((city) => isPointInSaudiArabia([city.lng, city.lat]))
|
||||
|
||||
const MAKERS = [
|
||||
"Maker A",
|
||||
"Maker B",
|
||||
"Maker C",
|
||||
"Maker D",
|
||||
"Maker E",
|
||||
"Maker F",
|
||||
"Maker G",
|
||||
"Maker H",
|
||||
"Maker I",
|
||||
"Maker J",
|
||||
]
|
||||
|
||||
function randomInRange(min, max) {
|
||||
return Math.random() * (max - min) + min
|
||||
}
|
||||
|
||||
function isWithinSaudiBounds(lng, lat) {
|
||||
// First do a quick bounding box check for performance
|
||||
if (
|
||||
lng < SAUDI_BOUNDS.minLng ||
|
||||
lng > SAUDI_BOUNDS.maxLng ||
|
||||
lat < SAUDI_BOUNDS.minLat ||
|
||||
lat > SAUDI_BOUNDS.maxLat
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Then do the precise polygon check
|
||||
return isPointInSaudiArabia([lng, lat])
|
||||
}
|
||||
|
||||
function generateCoordinatesAroundCity(city, radius = 0.3) {
|
||||
// Reduced radius for better accuracy
|
||||
let attempts = 0
|
||||
let coordinates
|
||||
|
||||
do {
|
||||
const angle = Math.random() * 2 * Math.PI
|
||||
const distance = Math.random() * radius
|
||||
|
||||
const lng = city.lng + distance * Math.cos(angle)
|
||||
const lat = city.lat + distance * Math.sin(angle)
|
||||
|
||||
coordinates = [lng, lat]
|
||||
attempts++
|
||||
} while (
|
||||
!isWithinSaudiBounds(coordinates[0], coordinates[1]) &&
|
||||
attempts < 20
|
||||
)
|
||||
|
||||
// If we can't find a valid coordinate around the city, use the city coordinates
|
||||
if (!isWithinSaudiBounds(coordinates[0], coordinates[1])) {
|
||||
coordinates = [city.lng, city.lat]
|
||||
}
|
||||
|
||||
return coordinates
|
||||
}
|
||||
|
||||
function generateRandomCoordinates() {
|
||||
let attempts = 0
|
||||
let coordinates
|
||||
|
||||
do {
|
||||
coordinates = [
|
||||
randomInRange(SAUDI_BOUNDS.minLng, SAUDI_BOUNDS.maxLng),
|
||||
randomInRange(SAUDI_BOUNDS.minLat, SAUDI_BOUNDS.maxLat),
|
||||
]
|
||||
attempts++
|
||||
} while (
|
||||
!isWithinSaudiBounds(coordinates[0], coordinates[1]) &&
|
||||
attempts < 20
|
||||
)
|
||||
|
||||
return coordinates
|
||||
}
|
||||
|
||||
function generateMeterFeature(id, coordinates) {
|
||||
const maker = MAKERS[Math.floor(Math.random() * MAKERS.length)]
|
||||
const model = `Model ${id}`
|
||||
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: coordinates,
|
||||
},
|
||||
properties: {
|
||||
id: `meter-${id}`,
|
||||
maker: maker,
|
||||
model: model,
|
||||
timestamp: "2025-08-13T11:47:00Z",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateMetersData(totalPoints = 1000) {
|
||||
const features = []
|
||||
|
||||
console.log(`Valid major cities within Saudi borders: ${MAJOR_CITIES.length}`)
|
||||
|
||||
// Generate 60% of points around major cities for realistic distribution
|
||||
const cityPoints = Math.floor(totalPoints * 0.6)
|
||||
const randomPoints = totalPoints - cityPoints
|
||||
|
||||
console.log(`Generating ${cityPoints} points around major cities...`)
|
||||
for (let i = 1; i <= cityPoints; i++) {
|
||||
const city = MAJOR_CITIES[Math.floor(Math.random() * MAJOR_CITIES.length)]
|
||||
const coordinates = generateCoordinatesAroundCity(city, 0.3) // Smaller radius for accuracy
|
||||
features.push(generateMeterFeature(i, coordinates))
|
||||
}
|
||||
|
||||
console.log(`Generating ${randomPoints} random points across Saudi Arabia...`)
|
||||
for (let i = cityPoints + 1; i <= totalPoints; i++) {
|
||||
const coordinates = generateRandomCoordinates()
|
||||
features.push(generateMeterFeature(i, coordinates))
|
||||
}
|
||||
|
||||
// Validate all coordinates are within actual Saudi borders
|
||||
const validFeatures = features.filter((feature) => {
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
return isWithinSaudiBounds(lng, lat)
|
||||
})
|
||||
|
||||
console.log(
|
||||
`Generated ${features.length} features, ${validFeatures.length} are within actual Saudi borders`
|
||||
)
|
||||
|
||||
// If we have fewer than desired points, generate more around cities
|
||||
if (validFeatures.length < totalPoints) {
|
||||
const needed = totalPoints - validFeatures.length
|
||||
console.log(`Generating ${needed} additional points around major cities...`)
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
const city = MAJOR_CITIES[Math.floor(Math.random() * MAJOR_CITIES.length)]
|
||||
const coordinates = generateCoordinatesAroundCity(city, 0.2) // Even smaller radius
|
||||
const newFeature = generateMeterFeature(
|
||||
validFeatures.length + i + 1,
|
||||
coordinates
|
||||
)
|
||||
validFeatures.push(newFeature)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: validFeatures.slice(0, totalPoints), // Ensure exactly the requested number
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the data
|
||||
const points =
|
||||
parseInt(process.env.POINTS, 10) ||
|
||||
(process.argv[2] ? parseInt(process.argv[2], 10) : 100000)
|
||||
|
||||
console.log(
|
||||
`Generating ${points} meter points across Saudi Arabia using actual borders...`
|
||||
)
|
||||
const metersData = generateMetersData(points)
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync("saudi_meters.json", JSON.stringify(metersData, null, 2))
|
||||
|
||||
console.log(
|
||||
`✅ Successfully generated ${metersData.features.length} meter points!`
|
||||
)
|
||||
console.log("📍 Distribution:")
|
||||
console.log(" - 60% around major cities")
|
||||
console.log(" - 40% randomly distributed")
|
||||
console.log(
|
||||
"🗺️ Coverage: Entire Saudi Arabia territory (using actual polygon borders)"
|
||||
)
|
||||
|
||||
// Final validation
|
||||
const invalidPoints = metersData.features.filter((feature) => {
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
return !isPointInSaudiArabia([lng, lat])
|
||||
})
|
||||
|
||||
console.log(
|
||||
`\n🔍 Final validation: ${invalidPoints.length} points outside Saudi borders`
|
||||
)
|
||||
if (invalidPoints.length === 0) {
|
||||
console.log("✅ All points are within Saudi Arabia's actual borders!")
|
||||
}
|
||||
Generated
+1534
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "test-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Express server for serving saudi_meters.json",
|
||||
"main": "app.ts",
|
||||
"scripts": {
|
||||
"start": "node dist/app.js",
|
||||
"dev": "tsx app.ts",
|
||||
"build": "tsc",
|
||||
"generate": "node generate_meters.js",
|
||||
"validate": "node validate_borders.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"express": "5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "24.3.0",
|
||||
"tsx": "4.20.4",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
Generated
+1001
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": [
|
||||
"ES2020"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"shared-types/*": [
|
||||
"../types/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"../types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"shared-types/*": [
|
||||
"./types/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Shared types index
|
||||
* Re-exports all type definitions for easy importing
|
||||
*/
|
||||
|
||||
export * from "./meters"
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Shared type definitions for Saudi Meters data
|
||||
* Used by both frontend and backend projects
|
||||
*/
|
||||
|
||||
export interface MeterProperties {
|
||||
id: string
|
||||
maker: string
|
||||
model: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface MeterFeature {
|
||||
type: "Feature"
|
||||
geometry: {
|
||||
type: "Point"
|
||||
coordinates: [number, number]
|
||||
}
|
||||
properties: MeterProperties
|
||||
}
|
||||
|
||||
export interface MetersCollection {
|
||||
type: "FeatureCollection"
|
||||
features: MeterFeature[]
|
||||
}
|
||||
|
||||
// Type alias for convenience
|
||||
export type GeoJSONMeterData = MetersCollection
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fileURLToPath, URL } from "node:url"
|
||||
|
||||
import { defineConfig } from "vite"
|
||||
import vue from "@vitejs/plugin-vue"
|
||||
import vueDevTools from "vite-plugin-vue-devtools"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), vueDevTools()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
"shared-types": fileURLToPath(new URL("./types", import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user