├── public ├── .htaccess ├── logo.png └── favicon.png ├── .eslintrc.json ├── postcss.config.js ├── prettier.config.js ├── .env.local.example ├── next-env.d.ts ├── pages ├── _app.tsx ├── _document.tsx ├── index.tsx └── details │ └── [counter].tsx ├── next.config.js ├── tailwind.config.js ├── Makefile ├── tsconfig.json ├── .prettierignore ├── .gitignore ├── .github └── workflows │ ├── formatting.yml │ └── nextjs.yml ├── data └── locale_fr.ts ├── components ├── single_marker.tsx ├── heatmap.tsx ├── map.tsx ├── counter_tile.tsx └── plot.tsx ├── package.json ├── LICENSE ├── styles └── styles.css ├── lib └── types.d.ts ├── README.md └── prepare.sql /public/.htaccess: -------------------------------------------------------------------------------- 1 | RedirectMatch 301 ^/details/([a-zA-Z-']*)$ /details/$1.html 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tristramg/velos-paris/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tristramg/velos-paris/HEAD/public/favicon.png -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "next/typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_MAPBOX_TOKEN=get_your_own_token 2 | NEXT_PUBLIC_MAPBOX_CENTER="48.86,2.34" 3 | NEXT_PUBLIC_MAPBOX_ZOOM=11.4 4 | BASE_PATH=base_path_of_next_app_should_start_with_/ 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/styles.css'; 2 | import { Inter } from 'next/font/google' 3 | const inter = Inter({ subsets: ['latin'] }) 4 | 5 | function MyApp({ Component, pageProps }) { 6 | 7 | return <> 8 | 13 | 14 | ; 15 | } 16 | 17 | export default MyApp; 18 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | basePath: process.env.BASE_PATH, 3 | output: 'export', 4 | serverExternalPackages: ['duckdb', 'duckdb-async'], 5 | images: { 6 | unoptimized: true, 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: 'filer.eco-counter-tools.com', 11 | port: '', 12 | pathname: '/file/**', 13 | search: '', 14 | }, 15 | ], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./pages/**/*.tsx', './components/**/*.tsx'], 3 | theme: { 4 | extend: {}, 5 | borderRadius: { 6 | none: '0', 7 | sm: '0.125rem', 8 | default: '0.25rem', 9 | md: '0.375rem', 10 | lg: '0.5rem', 11 | xl: '0.75rem', 12 | full: '9999px', 13 | }, 14 | }, 15 | variants: { 16 | backgroundColor: ['odd'], 17 | }, 18 | plugins: [], 19 | }; 20 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | data: 2 | wget "https://parisdata.opendatasoft.com/api/v2/catalog/datasets/comptage-velo-donnees-compteurs/exports/csv?rows=-1&select=id_compteur%2Csum_counts%2Cdate&timezone=UTC" -O public/compteurs.csv 3 | metadata: 4 | wget "https://parisdata.opendatasoft.com/api/v2/catalog/datasets/comptage-velo-compteurs/exports/csv" -O public/metadata.csv 5 | multimodal: 6 | wget "https://opendata.paris.fr/api/explore/v2.1/catalog/datasets/comptage-multimodal-comptages/exports/parquet?lang=fr&timezone=Europe%2FParis" -O public/comptage-multimodal-comptages.parquet 7 | .PHONY: data metadata 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | package-lock.json 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # data 38 | public 39 | 40 | .vscode 41 | .idea 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # data 37 | public/*.csv 38 | 39 | .vscode 40 | .idea 41 | compteurs.duckdb 42 | *.parquet 43 | -------------------------------------------------------------------------------- /.github/workflows/formatting.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | 3 | # Run this workflow every time a new commit pushed to your repository 4 | on: pull_request 5 | 6 | jobs: 7 | # Set the job key. The key is displayed as the job name 8 | # when a job name is not provided 9 | prettier: 10 | # Name the Job 11 | name: Formatting 12 | # Set the type of machine to run on 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | # Checks out a copy of your repository on the ubuntu-latest machine 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | - name: Install dependencies 20 | run: npm install 21 | - name: Run the linter 22 | run: npm run lint 23 | -------------------------------------------------------------------------------- /data/locale_fr.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | dateTime: '%A %e %B %Y à %X', 3 | date: '%d/%m/%Y', 4 | time: '%H:%M:%S', 5 | periods: ['AM', 'PM'], 6 | days: [ 7 | 'dimanche', 8 | 'lundi', 9 | 'mardi', 10 | 'mercredi', 11 | 'jeudi', 12 | 'vendredi', 13 | 'samedi', 14 | ], 15 | shortDays: ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'], 16 | months: [ 17 | 'janvier', 18 | 'février', 19 | 'mars', 20 | 'avril', 21 | 'mai', 22 | 'juin', 23 | 'juillet', 24 | 'août', 25 | 'septembre', 26 | 'octobre', 27 | 'novembre', 28 | 'décembre', 29 | ], 30 | shortMonths: [ 31 | 'janv.', 32 | 'févr.', 33 | 'mars', 34 | 'avr.', 35 | 'mai', 36 | 'juin', 37 | 'juil.', 38 | 'aout', 39 | 'sept.', 40 | 'oct.', 41 | 'nov.', 42 | 'déc.', 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /components/single_marker.tsx: -------------------------------------------------------------------------------- 1 | import mapboxgl from 'mapbox-gl'; 2 | import React, { useEffect, useRef } from 'react'; 3 | 4 | const Map = ({ coord }: { coord: [number, number] }) => { 5 | const mapContainer = useRef(null); 6 | 7 | mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; 8 | 9 | // useEffect for the initialization of the map 10 | useEffect(() => { 11 | const newMap = new mapboxgl.Map({ 12 | container: mapContainer.current, 13 | style: 'mapbox://styles/mapbox/streets-v11', 14 | center: coord, 15 | zoom: 16, 16 | }); 17 | newMap.on('load', () => { 18 | newMap.resize(); 19 | new mapboxgl.Marker().setLngLat(coord).addTo(newMap); 20 | }); 21 | }, [coord]); 22 | 23 | return ( 24 |
29 | ); 30 | }; 31 | 32 | export default Map; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counters", 3 | "version": "0.2.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "d3-scale": "4.0.2", 13 | "lodash": "4.17.21", 14 | "luxon": "3.5.0", 15 | "mapbox-gl": "^2.15.0", 16 | "next": "15.1.0", 17 | "vega": "^5.20.0", 18 | "vega-embed": "^6.17.0", 19 | "vega-lite": "^5.0.0" 20 | }, 21 | "devDependencies": { 22 | "@duckdb/node-api": "^1.1.3-alpha.4", 23 | "@types/d3-scale": "^4.0.6", 24 | "@types/lodash": "^4.14.161", 25 | "@types/luxon": "^3.3.3", 26 | "@types/mapbox-gl": "^2.7.17", 27 | "@types/node": "22.10.2", 28 | "@types/react": "19.0.1", 29 | "autoprefixer": "^10.4.20", 30 | "eslint": "9.17.0", 31 | "eslint-config-next": "15.1.0", 32 | "postcss-preset-env": "10.1.2", 33 | "tailwindcss": "3.4.16", 34 | "typescript": "5.7.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tristram Gräbener 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /styles/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | /* Write your own custom base styles here */ 4 | html { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | body { 10 | font-family: 11 | Lato, 12 | system-ui, 13 | -apple-system, 14 | sans-serif; 15 | @apply px-4; 16 | @apply py-1; 17 | @apply bg-gray-200; 18 | } 19 | 20 | * { 21 | box-sizing: border-box; 22 | } 23 | 24 | a, 25 | a:active { 26 | @apply text-blue-600; 27 | } 28 | 29 | a:hover { 30 | @apply text-blue-700; 31 | @apply underline; 32 | } 33 | 34 | dl { 35 | @apply grid; 36 | grid-template-columns: 100px 1fr; 37 | } 38 | 39 | dt { 40 | @apply col-start-1; 41 | font-size: 14; 42 | color: #848280; 43 | } 44 | 45 | dd { 46 | @apply col-start-2; 47 | @apply text-right; 48 | } 49 | 50 | h1 { 51 | @apply text-4xl; 52 | @apply font-bold; 53 | } 54 | 55 | h2 { 56 | @apply text-xl; 57 | @apply font-bold; 58 | @apply leading-5; 59 | } 60 | 61 | h3 { 62 | @apply font-bold; 63 | } 64 | 65 | .grey { 66 | color: #848280; 67 | } 68 | 69 | /* Start purging... */ 70 | @tailwind components; 71 | /* Stop purging. */ 72 | 73 | /* Write you own custom component styles here */ 74 | 75 | /* Start purging... */ 76 | @tailwind utilities; 77 | /* Stop purging. */ 78 | -------------------------------------------------------------------------------- /lib/types.d.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | export type CounterSummary = { 4 | total: number; 5 | day: number; 6 | week: number; 7 | month: number; 8 | year: number; 9 | daysThisYear: number; 10 | minDate: DateTime; 11 | maxDate: DateTime; 12 | }; 13 | 14 | export type CounterMetadata = { 15 | id_compteur: string; 16 | nom_compteur: string; 17 | id: string; 18 | name: string; 19 | channel_id: string; 20 | channel_name: string; 21 | installation_date: string; 22 | url_photos_n1: string; 23 | coordinates: string; 24 | }; 25 | 26 | export type CounterStat = { 27 | id: string; 28 | slug: string; 29 | days: number; 30 | day: number; 31 | week: number; 32 | month: number; 33 | year: number; 34 | total: number; 35 | daysThisYear: number; 36 | included: string[]; 37 | coordinates: [number, number]; 38 | }; 39 | 40 | export type CounterDetails = { 41 | time: string; 42 | count: number; 43 | id: string; 44 | }; 45 | 46 | export type ParsedCount = { 47 | time: Date; 48 | count: number; 49 | }; 50 | 51 | export type Detail = { 52 | name: string; 53 | img: string; 54 | date: string; 55 | coord: [number, number]; 56 | }; 57 | 58 | export type Counter = { 59 | title: string; 60 | details: Detail[]; 61 | week: CounterDetails[]; 62 | month: CounterDetails[]; 63 | year: CounterDetails[]; 64 | year_daily: CounterDetails[]; 65 | hour_record: CounterDetails; 66 | day_record: CounterDetails; 67 | week_record: CounterDetails; 68 | }; 69 | -------------------------------------------------------------------------------- /components/heatmap.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from '../lib/types'; 2 | import React, { useEffect, useRef } from 'react'; 3 | import { TopLevelSpec as VlSpec } from 'vega-lite'; 4 | import vegaEmbed from 'vega-embed'; 5 | import timeFormatLocale from '../data/locale_fr'; 6 | 7 | type Props = { 8 | counters: Counter; 9 | }; 10 | const Heatmap = ({ counters }: Props) => { 11 | const container = useRef(null); 12 | 13 | useEffect(() => { 14 | const vegaSpec: VlSpec = { 15 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json', 16 | data: { 17 | values: counters['year_daily'], 18 | }, 19 | title: 'Passages journaliers', 20 | config: { view: { strokeWidth: 0, step: 15 }, axis: { domain: false } }, 21 | mark: { type: 'rect', height: 6 }, 22 | encoding: { 23 | x: { 24 | field: 'time', 25 | timeUnit: 'day', 26 | type: 'ordinal', 27 | title: 'Jour de la semaine', 28 | sort: [ 29 | 'Monday', 30 | 'Tuesday', 31 | 'Wednesday', 32 | 'Thursday', 33 | 'Friday', 34 | 'Saturday', 35 | 'Sunday', 36 | ], 37 | }, 38 | y: { 39 | field: 'time', 40 | timeUnit: 'yearweek', 41 | type: 'ordinal', 42 | title: 'Semaine', 43 | scale: { 44 | padding: -3, 45 | }, 46 | }, 47 | color: { 48 | field: 'count', 49 | aggregate: 'sum', 50 | type: 'quantitative', 51 | legend: { title: 'Passages' }, 52 | scale: { scheme: 'viridis' }, 53 | }, 54 | tooltip: [ 55 | { 56 | field: 'time', 57 | title: 'Date', 58 | type: 'temporal', 59 | format: '%e %b %Y', 60 | }, 61 | { field: 'count', aggregate: 'sum', title: 'Passages' }, 62 | ], 63 | }, 64 | }; 65 | vegaEmbed(container.current, vegaSpec, { timeFormatLocale }).then((r) => r); 66 | }, [counters]); 67 | 68 | return ( 69 |
70 | ); 71 | }; 72 | 73 | export default Heatmap; 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ce petit site récupère les données ouvertes des compteurs de passages à vélo de la ville de Paris pour en faire une présentation synthétique. 2 | 3 | En particulier on peut : 4 | 5 | - Comparer les principaux compteurs 6 | - Pour un compteur donné, voir les chiffre par heure de la veille, par jour du dernier mois, ou par semaine sur l’année en cours 7 | 8 | ## Obtenir les données 9 | 10 | Des capteur (« boucles ») sont installées au sein du goudron un peu partout dans Paris. Cette boucle détecte le passage d’un vélo et remonte la donnée qui est exposée sur le portail OpenData de la Ville. 11 | 12 | Elles sont mise à jour une fois par jour et découpées en deux fichiers : 13 | 14 | Le premier qui contient les données de comptage à proprement parler (une mesure par heure et par compteur) : 15 | `wget "https://parisdata.opendatasoft.com/api/v2/catalog/datasets/comptage-velo-donnees-compteurs/exports/csv?rows=-1&select=id_compteur%2Csum_counts%2Cdate&timezone=UTC" -O public/compteurs.csv` 16 | 17 | Le deuxième contient des informations supplémentaire sur chaque compteur (comme une photo du compteur, son emplacement…) 18 | `wget "https://parisdata.opendatasoft.com/api/v2/catalog/datasets/comptage-velo-compteurs/exports/csv" -O public/metadata.csv` 19 | 20 | ## Lancer le projet 21 | 22 | C’est un projet [Next.js](https://nextjs.org/) et [Vega-Lite](https://vega.github.io/) pour dessiner les graphes. 23 | 24 | La carte utilise [Mapbox](https://mapbox.com) et un _token_ est nécessaire. 25 | Obtenez-en un et modifiez `.env.local.example` en le sauvegardant sous `.env.local`. 26 | 27 | Vous aurez besoin d’une installation de [Node.js](https://nodejs.org/) 28 | 29 | ```bash 30 | npm install 31 | ``` 32 | 33 | Afin de ne pas dépendre d’une base de données, les données sont préparées et intégrées statique à chaque page. 34 | 35 | Pour préparer les données, nous utilisons duckdb : 36 | 37 | ```bash 38 | duckdb compteurs.duckdb < prepare.sql 39 | ``` 40 | 41 | Et enfin pour lancer le projet : 42 | 43 | ```bash 44 | npm run dev 45 | ``` 46 | 47 | Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur pour voir le résultat. 48 | 49 | ## Déployer le projet 50 | 51 | Afin de maintenir le site à jour, il faut reconstruire le site à chaque jour avec l’arrivée de nouvelles données (entre 8 et 9h du matin). 52 | 53 | Téléchargez les données: 54 | 55 | ```bash 56 | make metadata 57 | make data 58 | ``` 59 | 60 | Exécutez : 61 | 62 | ```bash 63 | duckdb compteurs.duckdb < prepare.sql 64 | npm run build 65 | ``` 66 | 67 | Le repertoire `out` contiendra les fichier statiques à transférer sur le serveur web (celui-ci doit juste servir les fichiers. Il n’y a pas besoin d’avoir la moindre installation locale). 68 | -------------------------------------------------------------------------------- /components/map.tsx: -------------------------------------------------------------------------------- 1 | import mapboxgl from 'mapbox-gl'; 2 | import React, { useEffect, useState, useRef } from 'react'; 3 | import _ from 'lodash'; 4 | import * as d3 from 'd3-scale'; 5 | import { CounterStat } from '../lib/types.d'; 6 | 7 | const popupHTML = (counter: CounterStat): string => ` 8 |

${counter.id}

9 |

${counter.day} passages hier

10 | Voir les détails 11 | `; 12 | 13 | type Props = { 14 | counters: CounterStat[]; 15 | highlight: string; 16 | }; 17 | 18 | const options = (highlight: boolean, count: number, max: number) => { 19 | const colors = ['#20B4FF', '#8AD1A4', '#F5EE4A', '#FA7725', '#FE0000']; 20 | const scale = d3.scaleLinear( 21 | [0, max * 0.25, max * 0.5, max * 0.75, max], 22 | colors 23 | ); 24 | return { 25 | color: highlight ? '#CC8811' : scale(count), 26 | }; 27 | }; 28 | 29 | const buildMarker = ( 30 | counter: CounterStat, 31 | hl: boolean, 32 | max: number 33 | ): mapboxgl.Marker => 34 | new mapboxgl.Marker(options(hl, counter.day, max)) 35 | .setLngLat(counter.coordinates) 36 | .setPopup(new mapboxgl.Popup().setHTML(popupHTML(counter))); 37 | 38 | const Map = ({ counters, highlight }: Props) => { 39 | const [map, setMap] = useState(null); 40 | const [markers, setMarkers] = useState({}); 41 | const [lastMarker, setLastMarker] = useState(null); 42 | const mapContainer = useRef(null); 43 | const max = _.maxBy(counters, 'day').day; 44 | 45 | mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; 46 | 47 | // useEffect for the initialization of the map 48 | useEffect(() => { 49 | const newMap = new mapboxgl.Map({ 50 | container: mapContainer.current, 51 | style: 'mapbox://styles/mapbox/streets-v11', 52 | center: process.env.NEXT_PUBLIC_MAPBOX_CENTER.split(',').map( 53 | (c) => +c 54 | ) as [number, number], 55 | zoom: parseFloat(process.env.NEXT_PUBLIC_MAPBOX_ZOOM), 56 | }); 57 | newMap.addControl(new mapboxgl.NavigationControl()); 58 | newMap.on('load', () => { 59 | newMap.resize(); 60 | // We reverse to display the smallest counters on the bottom 61 | for (const counter of counters.slice().reverse()) { 62 | const marker = buildMarker(counter, false, max); 63 | marker.addTo(newMap); 64 | markers[counter.id] = marker; 65 | } 66 | setMap(newMap); 67 | }); 68 | }, [counters, markers, max]); 69 | 70 | // useEffect to handle the highlighted marker 71 | useEffect(() => { 72 | if (lastMarker !== null) { 73 | markers[lastMarker].remove(); 74 | } 75 | const counter = counters.find((counter) => counter.id === highlight); 76 | if (counter) { 77 | setLastMarker(highlight); 78 | const marker = buildMarker(counter, true, max); 79 | marker.addTo(map); 80 | markers[counter.id] = marker; 81 | setMarkers(markers); 82 | map.flyTo({ center: counter.coordinates, zoom: 13.5 }); 83 | } 84 | }, [highlight, counters, lastMarker, map, markers, max]); 85 | 86 | return
; 87 | }; 88 | 89 | export default Map; 90 | -------------------------------------------------------------------------------- /components/counter_tile.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { CounterStat } from '../lib/types.d'; 3 | 4 | const Num = ({ n }: { n: number }) => ( 5 | {n.toLocaleString('fr-FR')} 6 | ); 7 | 8 | const medal = (rank: number): string => { 9 | if (rank === 1) { 10 | return '🥇'; 11 | } else if (rank === 2) { 12 | return '🥈'; 13 | } else if (rank === 3) { 14 | return '🥉'; 15 | } 16 | return ''; 17 | }; 18 | 19 | type Props = { 20 | stat: CounterStat; 21 | avg: boolean; 22 | rank: number; 23 | counterCount: number; 24 | click: () => void; 25 | }; 26 | 27 | function Counter({ stat, avg, rank, counterCount, click }: Props) { 28 | const week = avg ? Math.round(stat.week / 7) : stat.week; 29 | const month = avg ? Math.round(stat.month / 30) : stat.month; 30 | const year = avg ? Math.round(stat.year / stat.daysThisYear) : stat.year; 31 | const total = avg ? Math.round(stat.total / stat.days) : stat.total; 32 | return ( 33 |
34 |
35 | {medal(rank)} 36 |
37 | Top {rank}/{counterCount} 38 |
39 | 40 |
41 |

{stat.id}

42 |

43 | Voir la fréquentation détaillée{' '} 44 | 50 | 54 | 55 |

56 |
57 | 58 |
59 |
Hier
60 |
61 | 62 |
63 |
Sur 7 jours
64 |
65 | 66 |
67 |
Sur 30 jours
68 |
69 | 70 |
71 |
Sur {stat.days} jours
72 |
73 | 74 |
75 |
Cette année
76 |
77 | 78 |
79 |
Compteurs
80 |
81 |
    85 | {stat.included.map((counter) => ( 86 |
  • {counter}
  • 87 | ))} 88 |
89 |
90 |
91 |
92 | ); 93 | } 94 | 95 | export default Counter; 96 | -------------------------------------------------------------------------------- /.github/workflows/nextjs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Next.js site to Pages and paris en selle 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ['main'] 7 | schedule: 8 | - cron: '00 8 * * *' 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | fetch_data: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: fetch data 20 | run: make data 21 | - name: fetch metadata 22 | run: make metadata 23 | - name: upload artifact 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: opendata 27 | path: | 28 | public/compteurs.csv 29 | public/metadata.csv 30 | fetch_multimodal_data: # we parallelize because it’s terribly slow 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - name: fetch data 36 | run: make multimodal 37 | - name: fetch metadata 38 | run: make metadata 39 | - name: upload artifact 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: opendata_multimodal 43 | path: | 44 | public/comptage-multimodal-comptages.parquet 45 | dataprep: 46 | runs-on: ubuntu-latest 47 | needs: [fetch_data, fetch_multimodal_data] 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | - name: get the opendata 52 | uses: actions/download-artifact@v4 53 | with: 54 | name: opendata 55 | path: public 56 | - name: get the opendata (multimodal) 57 | uses: actions/download-artifact@v4 58 | with: 59 | name: opendata_multimodal 60 | path: public 61 | - name: get duckdb 62 | run: wget https://github.com/duckdb/duckdb/releases/download/v1.1.3/duckdb_cli-linux-amd64.zip && unzip duckdb_cli-linux-amd64.zip 63 | - name: prepare data 64 | run: ./duckdb compteurs.duckdb < prepare.sql 65 | - name: upload artifact 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: prepared 69 | path: compteurs.duckdb 70 | build: 71 | runs-on: ubuntu-latest 72 | needs: dataprep 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | - name: get the opendata 77 | uses: actions/download-artifact@v4 78 | with: 79 | name: prepared 80 | - name: Install dependencies 81 | run: npm install 82 | - name: Build with Next.js 83 | env: 84 | NEXT_PUBLIC_MAPBOX_TOKEN: ${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }} 85 | NEXT_PUBLIC_MAPBOX_CENTER: ${{ vars.NEXT_PUBLIC_MAPBOX_CENTER }} 86 | NEXT_PUBLIC_MAPBOX_ZOOM: ${{ vars.NEXT_PUBLIC_MAPBOX_ZOOM }} 87 | run: npm run build 88 | - name: upload artifact 89 | uses: actions/upload-artifact@v4 90 | with: 91 | name: website 92 | path: out 93 | deploy: 94 | runs-on: ubuntu-latest 95 | needs: build 96 | steps: 97 | - name: get the built site 98 | uses: actions/download-artifact@v4 99 | with: 100 | name: website 101 | path: public 102 | - name: install lftp 103 | run: sudo apt update && sudo apt install lftp --yes 104 | - name: transfert by ftp 105 | run: lftp -e "set ssl:verify-certificate no ; mirror -R --parallel=4 public /" -u "${{ secrets.CREDENTIALS }}" ftp-parisenselle.alwaysdata.net 106 | -------------------------------------------------------------------------------- /components/plot.tsx: -------------------------------------------------------------------------------- 1 | import { Counter, CounterDetails } from '../lib/types'; 2 | import React, { useEffect, useRef } from 'react'; 3 | import { TopLevelSpec as VlSpec } from 'vega-lite'; 4 | import vegaEmbed from 'vega-embed'; 5 | import timeFormatLocale from '../data/locale_fr'; 6 | 7 | type Props = { 8 | counters: Counter; 9 | period: string; 10 | }; 11 | const Plot = ({ counters, period }: Props) => { 12 | const container = useRef(null); 13 | const timeUnit = { 14 | day: 'yearmonthdatehours' as const, 15 | month: 'yearmonthdate' as const, 16 | year: 'yearweek' as const, 17 | }[period]; 18 | 19 | const timeLabel = { 20 | day: 'heure', 21 | month: 'jour', 22 | year: 'semaine', 23 | }[period]; 24 | 25 | const format = { 26 | day: '%H:%M', 27 | month: '%e %b %Y', 28 | year: 'Semaine %W (%d/%m/%Y)', 29 | }[period]; 30 | 31 | const axis = { 32 | day: { 33 | title: '', 34 | tickCount: 8, 35 | labelAlign: 'left' as const, 36 | labelExpr: 37 | "[timeFormat(datum.value, '%H:%M'), timeFormat(datum.value, '%H') == '00' ? timeFormat(datum.value, '%e %b') : '']", 38 | labelOffset: 4, 39 | labelPadding: -24, 40 | tickSize: 30, 41 | gridDash: { 42 | condition: { 43 | test: { field: 'value', timeUnit: 'hours' as const, equal: 0 }, 44 | value: [], 45 | }, 46 | value: [2, 2], 47 | }, 48 | tickDash: { 49 | condition: { 50 | test: { field: 'value', timeUnit: 'hours' as const, equal: 0 }, 51 | value: [], 52 | }, 53 | value: [2, 2], 54 | }, 55 | }, 56 | month: { 57 | formatType: 'time', 58 | format: '%e %b %Y', 59 | title: '', 60 | labelAngle: 30, 61 | }, 62 | year: { 63 | title: '', 64 | tickCount: { interval: 'week' as const, step: 10 }, 65 | labelAngle: 0, 66 | labelAlign: 'left' as const, 67 | labelExpr: 68 | "[timeFormat(datum.value, 'Semaine %W'), timeFormat(datum.value, '%m') == '01' ? timeFormat(datum.value, '%Y') : '']", 69 | labelOffset: 4, 70 | labelPadding: -24, 71 | tickSize: 30, 72 | gridDash: { 73 | condition: { 74 | test: { field: 'value', timeUnit: 'month' as const, equal: 1 }, 75 | value: [], 76 | }, 77 | value: [2, 2], 78 | }, 79 | tickDash: { 80 | condition: { 81 | test: { field: 'value', timeUnit: 'month' as const, equal: 1 }, 82 | value: [], 83 | }, 84 | value: [2, 2], 85 | }, 86 | }, 87 | }[period]; 88 | 89 | useEffect(() => { 90 | const data: CounterDetails[] = counters[period]; 91 | 92 | const vegaSpec: VlSpec = { 93 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json', 94 | description: 'Nombre de passages de vélo', 95 | data: { 96 | values: data, 97 | }, 98 | transform: [ 99 | { 100 | joinaggregate: [ 101 | { 102 | op: 'sum', 103 | field: 'count', 104 | as: 'total', 105 | }, 106 | ], 107 | groupby: ['time'], 108 | }, 109 | ], 110 | width: 'container', 111 | mark: 'bar', 112 | encoding: { 113 | x: { 114 | field: 'time', 115 | axis, 116 | timeUnit, 117 | }, 118 | y: { 119 | field: 'count', 120 | type: 'quantitative', 121 | axis: { title: 'Passages par ' + timeLabel }, 122 | }, 123 | color: { 124 | field: 'id', 125 | legend: { title: 'Compteur' }, 126 | scale: { range: ['#75CBB7', '#CAE26E', '#612798', '#fee9ff', '#837090'] }, 127 | }, 128 | tooltip: [ 129 | { field: 'id', title: 'Sens' }, 130 | { field: 'time', title: 'Moment', type: 'temporal', format }, 131 | { field: 'count', title: 'Passages' }, 132 | { field: 'total', title: 'Passages total' }, 133 | ], 134 | }, 135 | }; 136 | 137 | vegaEmbed(container.current, vegaSpec, { timeFormatLocale }).then((r) => r); 138 | }); 139 | 140 | return ( 141 |
142 | ); 143 | }; 144 | 145 | export default Plot; 146 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { CounterStat } from '../lib/types.d'; 4 | import { useState } from 'react'; 5 | import _ from 'lodash'; 6 | 7 | import Counter from '../components/counter_tile'; 8 | import Map from '../components/map'; 9 | import { Metadata } from 'next'; 10 | import { DuckDBInstance, DuckDBListValue } from '@duckdb/node-api'; 11 | import { DateTime } from 'luxon'; 12 | 13 | type Props = { 14 | counts: CounterStat[]; 15 | buildTime: string; 16 | }; 17 | 18 | type StaticProps = { 19 | props: Props; 20 | }; 21 | 22 | export const getStaticProps = async (): Promise => { 23 | const instance = await DuckDBInstance.create('compteurs.duckdb', { 24 | access_mode: 'READ_ONLY', 25 | }); 26 | const connection = await instance.connect(); 27 | 28 | const metadata = await connection.run( 29 | 'SELECT name, slug, longitude, latitude, yesterday, week, month, year, total, days, days_this_year, single_counters FROM counter_group ORDER BY yesterday DESC' 30 | ); 31 | const rows = await metadata.getRows(); 32 | const counts = rows.map((row) => { 33 | return { 34 | id: row[0].toString(), 35 | slug: row[1].toString(), 36 | day: (row[4] as number) || 0, 37 | week: (row[5] as number) || 0, 38 | month: (row[6] as number) || 0, 39 | year: (row[7] as number) || 0, 40 | total: (row[8] as number) || 0, 41 | days: (row[9] as number) || 0, 42 | daysThisYear: (row[10] as number) || 0, 43 | included: (row[11] as DuckDBListValue).items as string[], 44 | coordinates: [(row[2] as number) || 0, (row[3] as number) || 0] as [ 45 | number, 46 | number, 47 | ], 48 | }; 49 | }); 50 | 51 | return { 52 | props: { 53 | counts, 54 | buildTime: DateTime.local().toFormat('dd/LL/yyyy à HH:mm'), 55 | }, 56 | }; 57 | }; 58 | 59 | export const metadata: Metadata = { 60 | title: 'Compteurs vélo à Paris', 61 | }; 62 | 63 | export default function AllCounters({ counts, buildTime }: Props) { 64 | const [stats, setStats] = useState(counts); 65 | const [highlight, setHighlight] = useState(null); 66 | const [avg, setAvg] = useState(true); 67 | 68 | return ( 69 | <> 70 |
71 | 72 | Logo Paris en Selle 79 | 80 |

Compteurs vélo à Paris

81 |

Page générée le {buildTime}

82 |
83 |
84 |
85 |
86 | setAvg(true)} 92 | >{' '} 93 | 94 |
95 |
96 | setAvg(false)} 101 | >{' '} 102 | 103 |
104 |
105 |
106 | Trier par activité décroissante sur : 107 |
108 | 122 |
123 | 128 | 129 | 130 |
131 |
132 |
133 |
134 | 135 |
136 |
137 | 138 |
139 | {_.map(stats, (stat, index) => ( 140 |
141 | setHighlight(stat.id)} 147 | /> 148 |
149 | ))} 150 |
151 |
152 |

153 | Source :{' '} 154 | 155 | données ouvertes de la ville de Paris 156 | 157 |

158 |

159 | Données sous licence{' '} 160 | ODbL 161 |

162 |

163 | 164 | Code source de la page 165 | {' '} 166 | sous licence MIT 167 |

168 |
169 | 170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /pages/details/[counter].tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image' 3 | import { GetStaticPaths, GetStaticProps, Metadata } from 'next'; 4 | 5 | import Plot from '../../components/plot'; 6 | import SingleMarker from '../../components/single_marker'; 7 | import { Detail, Counter, CounterDetails } from '../../lib/types'; 8 | import Heatmap from '../../components/heatmap'; 9 | import { DateTime } from 'luxon'; 10 | import { DuckDBInstance } from '@duckdb/node-api'; 11 | 12 | const counter_data = (table, slug, constraint) => ` 13 | SELECT channel_name, sum_counts, date 14 | FROM ${table}_view 15 | WHERE slug='${slug}' 16 | ${constraint} 17 | `; 18 | 19 | const data = async (table, slug, connection, constraint = '') => { 20 | const counts = await connection.run(counter_data(table, slug, constraint)); 21 | const rows = await counts.getRows(); 22 | return rows.map((row) => ({ 23 | id: row[0], 24 | count: row[1], 25 | time: row[2], 26 | })); 27 | }; 28 | 29 | const getRecords = async (slug, connection) => { 30 | const query = ` 31 | SELECT 32 | hour_record, hour_record_date, 33 | day_record, day_record_date, 34 | week_record, week_record_date, 35 | FROM records 36 | WHERE slug='${slug}'`; 37 | 38 | const records = await connection.run(query); 39 | const row = await records.getRows(); 40 | return { 41 | hour: { 42 | count: row[0][0], 43 | time: row[0][1], 44 | }, 45 | day: { 46 | count: row[0][2], 47 | time: row[0][3], 48 | }, 49 | week: { 50 | count: row[0][4], 51 | time: row[0][5], 52 | }, 53 | }; 54 | }; 55 | 56 | const getSingleCounters = async (slug, connection) => { 57 | const query = ` 58 | SELECT 59 | nom_compteur, url_photo, single_counter.installation_date::TEXT, 60 | single_counter.longitude, single_counter.latitude 61 | FROM single_counter 62 | JOIN counter_group ON single_counter.name = counter_group.name 63 | WHERE slug='${slug}'`; 64 | 65 | const result = await connection.run(query); 66 | const counters = await result.getRows(); 67 | return counters.map((counter) => ({ 68 | name: counter[0], 69 | img: counter[1], 70 | date: counter[2], 71 | coord: [counter[3], counter[4]], 72 | })); 73 | }; 74 | 75 | export const getStaticProps: GetStaticProps = async ({ params }) => { 76 | const instance = await DuckDBInstance.create('compteurs.duckdb', { 77 | access_mode: 'READ_ONLY', 78 | }); 79 | const connection = await instance.connect(); 80 | const name = await connection.run( 81 | `SELECT name, longitude, latitude FROM counter_group WHERE slug = '${params.counter}'` 82 | ); 83 | const metadata = await name.getRows(); 84 | const records = await getRecords(params.counter, connection); 85 | 86 | return { 87 | props: { 88 | details: { 89 | title: metadata[0][0].toString(), 90 | hour_record: records.hour, //record(sorted, { minute: 0, second: 0 }), 91 | day_record: records.day, //record(sorted, dayFmt), 92 | week_record: records.week, //record(sorted, weekFmt), 93 | day: await data('hourly', params.counter, connection), 94 | month: await data( 95 | 'daily', 96 | params.counter, 97 | connection, 98 | 'AND (date::date).date_add(INTERVAL 31 DAY) > current_date' 99 | ), 100 | year: await data('weekly', params.counter, connection), 101 | year_daily: await data('daily', params.counter, connection), 102 | buildTime: DateTime.local().toFormat('dd/LL/yyyy à HH:mm'), 103 | details: await getSingleCounters(params.counter, connection), 104 | coord: [metadata[0][1], metadata[0][2]], 105 | }, 106 | }, 107 | }; 108 | }; 109 | 110 | export const getStaticPaths: GetStaticPaths = async () => { 111 | const instance = await DuckDBInstance.create('compteurs.duckdb', { 112 | access_mode: 'READ_ONLY', 113 | }); 114 | const connection = await instance.connect(); 115 | 116 | const metadata = await connection.run('SELECT slug FROM counter_group'); 117 | const rows = await metadata.getRows(); 118 | const paths = rows.map((row) => ({ 119 | params: { 120 | counter: row[0].toString(), 121 | }, 122 | })); 123 | 124 | return { 125 | paths, 126 | fallback: false, 127 | }; 128 | }; 129 | 130 | const fmtDate = (detail: CounterDetails, format: string): string => { 131 | return DateTime.fromISO(detail.time).toFormat(format); 132 | }; 133 | 134 | const ImageComponent = function ({ detail }: { detail: Detail }) { 135 | if (detail.img) { 136 | return ( 137 | 138 | {`Image 139 | 140 | ); 141 | } 142 | return null; 143 | }; 144 | 145 | const DetailComponent = ({ detail }: { detail: Detail }) => ( 146 |
147 |

{detail.name}

148 | {detail.date &&

Installé le {detail.date}

} 149 | 150 | 151 |
152 | ); 153 | 154 | type Props = { 155 | details: Counter; 156 | buildTime: string; 157 | }; 158 | 159 | export async function generateMetadata({ details }: Props): Promise { 160 | return { 161 | title: `Détails du comptage ${details.title} `, 162 | }; 163 | } 164 | 165 | export default function Counters({ details, buildTime }: Props) { 166 | return ( 167 | <> 168 |
169 | 170 | Logo Paris en Selle 177 | 178 |

Détails du comptage {details.title}

179 |

Page générée le {buildTime}

180 |
181 | 182 | Retour à l’accueil 183 | 184 |
185 |
186 | {details.details.map((single_counter) => ( 187 | 191 | ))} 192 |
193 |
194 |
195 |

Records de passage

196 |
    197 |
  • 198 | Sur une heure :{' '} 199 | {details.hour_record.count}, 200 | le {fmtDate(details.hour_record, 'dd/LL/yyyy à HH:mm')} 201 |
  • 202 |
  • 203 | Sur une journée :{' '} 204 | {details.day_record.count}, 205 | le {fmtDate(details.day_record, 'dd/LL/yyyy')} 206 |
  • 207 |
  • 208 | Sur une semaine :{' '} 209 | {details.week_record.count}, 210 | la semaine du {fmtDate(details.week_record, 'dd/LL/yyyy')} 211 |
  • 212 |
213 |
214 | 215 | 216 | 217 | 218 |
219 |
220 | 221 | ); 222 | } 223 | -------------------------------------------------------------------------------- /prepare.sql: -------------------------------------------------------------------------------- 1 | -- We get two datasets from Paris’ opendata 2 | -- * metadata.csv that has one line per counter identified by `id_compteur` 3 | -- * compteurs.csv that has the number of cyclists for each counter 4 | -- 5 | -- Each counter (called “channel”) is a physical apparatus on the road 6 | -- They are often in pairs that are identified by `name` 7 | -- We separate them into `single_counter` and `counter_group` 8 | 9 | -- We need the spatial extension to manipulate coordinates from parquet files 10 | INSTALL spatial; 11 | LOAD spatial; 12 | 13 | CREATE TABLE single_counter AS 14 | SELECT 15 | name, 16 | id_compteur, 17 | channel_name, 18 | nom_compteur, 19 | url_photos_n1 as url_photo, 20 | installation_date, 21 | coordinates.string_split(',')[2]::float8 as longitude, 22 | coordinates.string_split(',')[1]::float8 as latitude, 23 | FROM read_csv('public/metadata.csv'); 24 | 25 | CREATE INDEX single_counter_idx ON single_counter(id_compteur); 26 | 27 | -- Fix Some channel names 28 | UPDATE single_counter 29 | SET channel_name = channel_name 30 | .replace('185 rue de Bercy ', '') 31 | .replace('27 quai de la Tournelle Vélos ', '') 32 | .replace('29 boulevard des Batignolles ', '') 33 | .replace('63 boulevard des Invalides Vélos ', '') 34 | .replace('Face au 8 avenue de la Porte de Châtillon ', '') 35 | .replace('Face au 48 quai de la marne Vélos ', '') 36 | .replace('Face au 8 avenue de la Porte de Châtillon Vélos ', '') 37 | .replace('Face au 81 Quai d''Orsay Vélos ', '') 38 | .replace('Quai des Tuileries Vélos ', '') 39 | .replace('Totem 64 Rue de Rivoli Vélos ', '') 40 | .replace('''', '’') 41 | .trim(), 42 | name = name 43 | .replace('Face au ', '') 44 | .replace('Face ', '') 45 | .replace('Totem', '') 46 | .replace('''', '’') 47 | .regexp_replace('^\d+', '') 48 | .trim(); 49 | 50 | -- Define the aggregations we want: a group can have multiple counters, like one for each direction 51 | CREATE VIEW counter_group_merged AS 52 | SELECT 53 | name, 54 | longitude, latitude, 55 | installation_date, 56 | date, 57 | sum_counts, 58 | channel_name 59 | FROM single_counter 60 | JOIN 'public/compteurs.csv' AS counts ON single_counter.id_compteur = counts.id_compteur 61 | 62 | UNION 63 | 64 | SELECT 65 | label.regexp_replace('^CF\d+_\d+', 'par caméra'), 66 | st_x(coordonnees_geo), st_y(coordonnees_geo), 67 | t, 68 | t, 69 | nb_usagers, 70 | mode 71 | FROM 'public/comptage-multimodal-comptages.parquet' 72 | WHERE 73 | label IS NOT NULL 74 | AND mode IN ('Vélos'); 75 | 76 | CREATE TABLE counter_group AS 77 | SELECT 78 | name, 79 | name.lower().strip_accents().replace(' ', '-').replace('''', '') as slug, 80 | avg(longitude) as longitude, 81 | avg(latitude) as latitude, 82 | min(installation_date) as installation_date, 83 | (sum(sum_counts) FILTER (date.date_add(INTERVAL 1 DAY) > current_date))::INTEGER as yesterday, 84 | (sum(sum_counts) FILTER (date.date_add(INTERVAL 7 DAY) > current_date))::INTEGER as week, 85 | (sum(sum_counts) FILTER (date.date_add(INTERVAL 30 DAY) > current_date))::INTEGER as month, 86 | (sum(sum_counts) FILTER (extract('year' FROM date) = extract('year' FROM current_date)))::INTEGER as year, 87 | sum(sum_counts)::INTEGER as total, 88 | (current_date - date_trunc('year', current_date))::INTEGER as days_this_year, 89 | date_part('day', current_date - min(date))::INTEGER as days, 90 | array_agg(distinct channel_name) as single_counters, 91 | FROM counter_group_merged 92 | GROUP BY name, slug; 93 | 94 | INSERT INTO single_counter (name, id_compteur, channel_name, nom_compteur, url_photo, installation_date, longitude, latitude) 95 | SELECT 96 | any_value(label.regexp_replace('^CF\d+_\d+', 'par caméra')), 97 | id_site || mode as id_compteur, 98 | mode as channel_name, 99 | mode as nom_compteur, 100 | '' as url_photo, 101 | min(t) as installation_date, 102 | any_value(st_x(coordonnees_geo)) as longitude, 103 | any_value(st_y(coordonnees_geo)) as latitude, 104 | FROM 105 | 'public/comptage-multimodal-comptages.parquet' 106 | WHERE 107 | mode IN ('Vélos') 108 | GROUP BY id_site, mode; 109 | 110 | 111 | CREATE VIEW merged_counters AS 112 | SELECT 113 | id_compteur, 114 | sum_counts, 115 | date 116 | FROM 'public/compteurs.csv' 117 | UNION 118 | SELECT 119 | id_site || mode, 120 | nb_usagers, 121 | t 122 | FROM 'public/comptage-multimodal-comptages.parquet' 123 | WHERE mode IN ('Vélos'); 124 | 125 | -- Create a table by timespan we want to represent 126 | -- The dates are exported as text because it will be serialized as json 127 | CREATE TABLE hourly AS 128 | SELECT 129 | id_compteur, 130 | sum_counts::INTEGER as sum_counts, 131 | strftime(date, '%Y-%m-%dT%H:%M:%S') as date, 132 | FROM merged_counters AS counts 133 | WHERE date.date_add(INTERVAL 2 DAY) > current_date; 134 | 135 | CREATE INDEX hourly_idx ON hourly(id_compteur); 136 | 137 | CREATE TABLE daily AS 138 | SELECT 139 | id_compteur, 140 | sum(sum_counts)::INTEGER AS sum_counts, 141 | date_trunc('day', date).strftime('%Y-%m-%d') as date, 142 | FROM merged_counters 143 | GROUP BY id_compteur, date_trunc('day', date); 144 | 145 | CREATE INDEX daily_idx ON daily(id_compteur); 146 | 147 | CREATE TABLE weekly AS 148 | SELECT 149 | id_compteur, 150 | sum(sum_counts)::INTEGER AS sum_counts, 151 | date_trunc('week', date).strftime('%Y-%m-%d') as date 152 | FROM merged_counters 153 | GROUP BY id_compteur, date_trunc('week', date); 154 | 155 | CREATE INDEX weekly_idx ON weekly(id_compteur); 156 | 157 | -- Create some view to facilitate the querying 158 | 159 | CREATE VIEW hourly_view AS 160 | SELECT hourly.*, channel_name, slug 161 | FROM hourly 162 | JOIN single_counter ON single_counter.id_compteur = hourly.id_compteur 163 | JOIN counter_group ON single_counter.name = counter_group.name; 164 | 165 | CREATE VIEW daily_view AS 166 | SELECT daily.*, channel_name, slug 167 | FROM daily 168 | JOIN single_counter ON single_counter.id_compteur = daily.id_compteur 169 | JOIN counter_group ON single_counter.name = counter_group.name; 170 | 171 | CREATE VIEW weekly_view AS 172 | SELECT weekly.*, channel_name, slug 173 | FROM weekly 174 | JOIN single_counter ON single_counter.id_compteur = weekly.id_compteur 175 | JOIN counter_group ON single_counter.name = counter_group.name; 176 | 177 | -- Now that we have the data by time period let’s store the records for every period 178 | CREATE VIEW records AS 179 | WITH 180 | hourly_by_group AS (SELECT SUM(sum_counts)::INTEGER as count, slug, date FROM hourly_view GROUP BY slug, date), 181 | daily_by_group AS (SELECT SUM(sum_counts)::INTEGER as count, slug, date FROM daily_view GROUP BY slug, date), 182 | weekly_by_group AS (SELECT SUM(sum_counts)::INTEGER as count, slug, date FROM weekly_view GROUP BY slug, date) 183 | SELECT 184 | counter_group.slug, 185 | ARGMAX(hourly_by_group.date, hourly_by_group.count) AS hour_record_date, max(hourly_by_group.count) AS hour_record, 186 | ARGMAX(daily_by_group.date, daily_by_group.count) AS day_record_date, max(daily_by_group.count) AS day_record, 187 | ARGMAX(weekly_by_group.date, weekly_by_group.count) AS week_record_date, max(weekly_by_group.count) AS week_record, 188 | FROM counter_group 189 | LEFT JOIN hourly_by_group ON counter_group.slug = hourly_by_group.slug 190 | LEFT JOIN daily_by_group ON counter_group.slug = daily_by_group.slug 191 | LEFT JOIN weekly_by_group ON counter_group.slug = weekly_by_group.slug 192 | GROUP BY counter_group.slug; 193 | --------------------------------------------------------------------------------