├── 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 |
79 |
80 |
Compteurs vélo à Paris
81 |
Page générée le {buildTime}
82 |
83 |
84 |
105 |
106 | Trier par activité décroissante sur :
107 |
108 |
113 | setStats(_.sortBy(stats, e.target.value).reverse())
114 | }
115 | >
116 | la journée de hier
117 | les 7 derniers jours
118 | les 30 derniers jours
119 | l’année en cours
120 | l’ancienneté
121 |
122 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | {_.map(stats, (stat, index) => (
140 |
141 | setHighlight(stat.id)}
147 | />
148 |
149 | ))}
150 |
151 |
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 |
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 |
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 |
--------------------------------------------------------------------------------