Skip to content

Commit 187cdd1

Browse files
Add a Map to the Conference statistics that shows the distribution of participants in Germany (#316)
1 parent 038649f commit 187cdd1

File tree

9 files changed

+387
-9
lines changed

9 files changed

+387
-9
lines changed

bun.lock

Lines changed: 98 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"concurrently": "^9.2.1",
5656
"convert-iso-codes": "^1.0.10",
5757
"cryptr": "^6.4.0",
58+
"csv-parse": "^5.4.9",
5859
"csv-stringify": "^6.6.0",
5960
"daisyui": "^5.5.5",
6061
"date-fns": "^4.1.0",
@@ -75,6 +76,7 @@
7576
"jose": "^6.1.3",
7677
"jsdom": "^27.2.0",
7778
"lefthook": "^1.13.6",
79+
"leaflet": "^1.9.4",
7880
"lodash": "^4.17.21",
7981
"marked": "^15.0.12",
8082
"mermaid": "^11.12.2",
@@ -95,6 +97,8 @@
9597
"prisma": "^6.19.0",
9698
"prisma-generator-pothos-codegen": "^0.7.1",
9799
"replace-special-characters": "^1.2.7",
100+
"sveaflet": "^0.1.4",
101+
"sveaflet-markercluster": "^0.0.3",
98102
"svelte": "^5.45.5",
99103
"svelte-check": "^4.3.4",
100104
"svelte-french-toast": "^1.2.0",

schema.graphql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3270,6 +3270,7 @@ enum SortOrder {
32703270
}
32713271

32723272
type StatisticsResult {
3273+
addresses: [StatisticsResultAddresses!]!
32733274
age: StatisticsResultRegisteredAge!
32743275
countdowns: StatisticsResultCountdowns!
32753276
diet: StatisticsResultRegisteredParticipantDiet!
@@ -3278,6 +3279,18 @@ type StatisticsResult {
32783279
status: StatisticsResultRegisteredParticipantStatus!
32793280
}
32803281

3282+
type StatisticsResultAddresses {
3283+
_count: StatisticsResultAddressesCount!
3284+
country: String
3285+
zip: String
3286+
}
3287+
3288+
type StatisticsResultAddressesCount {
3289+
_all: Int!
3290+
country: Int!
3291+
zip: Int!
3292+
}
3293+
32813294
type StatisticsResultCountdowns {
32823295
daysUntilConference: Int!
32833296
daysUntilEndRegistration: Int!

src/api/resolvers/modules/conference/statistics.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { requireToBeConferenceAdmin } from '$api/services/requireUserToBeConferenceAdmin';
22
import { conferenceStats } from '$api/services/stats';
33
import { db } from '$db/db';
4-
import { payment, postalRegistration } from '$lib/paraglide/messages';
54
import { builder } from '../../builder';
65

76
const dietVariations = builder.simpleObject('StatisticsResultRegisteredParticipantDietVariations', {
@@ -163,6 +162,38 @@ const StatisticsResult = builder.simpleObject('StatisticsResult', {
163162
didAttend: t.int()
164163
})
165164
})
165+
}),
166+
addresses: t.field({
167+
type: [
168+
t.builder
169+
.objectRef<{
170+
country: string | null;
171+
zip: string | null;
172+
_count: { zip: number; country: number; _all: number };
173+
}>('StatisticsResultAddresses')
174+
.implement({
175+
fields: (t) => ({
176+
_count: t.field({
177+
resolve: (parent) => parent._count,
178+
type: t.builder
179+
.objectRef<{
180+
zip: number;
181+
country: number;
182+
_all: number;
183+
}>('StatisticsResultAddressesCount')
184+
.implement({
185+
fields: (t) => ({
186+
country: t.exposeInt('country'),
187+
zip: t.exposeInt('zip'),
188+
_all: t.exposeInt('_all')
189+
})
190+
})
191+
}),
192+
country: t.exposeString('country', { nullable: true }),
193+
zip: t.exposeString('zip', { nullable: true })
194+
})
195+
})
196+
]
166197
})
167198
})
168199
});
@@ -178,11 +209,18 @@ builder.queryFields((t) => {
178209
const user = ctx.permissions.getLoggedInUserOrThrow();
179210
await requireToBeConferenceAdmin({ conferenceId: args.conferenceId, user });
180211

181-
const { countdowns, registrationStatistics, ageStatistics, diet, gender, status } =
182-
await conferenceStats({
183-
db,
184-
conferenceId: args.conferenceId
185-
});
212+
const {
213+
countdowns,
214+
registrationStatistics,
215+
ageStatistics,
216+
diet,
217+
gender,
218+
status,
219+
addresses
220+
} = await conferenceStats({
221+
db,
222+
conferenceId: args.conferenceId
223+
});
186224

187225
return {
188226
countdowns,
@@ -196,7 +234,8 @@ builder.queryFields((t) => {
196234
value: v
197235
}))
198236
},
199-
status
237+
status,
238+
addresses
200239
};
201240
}
202241
})

src/api/services/stats.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,12 +553,36 @@ export async function conferenceStats({
553553
didAttend: conferenceStatusStats.filter((s) => s.didAttend).length
554554
};
555555

556+
const addresses = await db.user.groupBy({
557+
by: ['country', 'zip'],
558+
where: {
559+
OR: [
560+
{ singleParticipant: { some: { conferenceId, applied: true } } },
561+
{
562+
delegationMemberships: {
563+
some: {
564+
conferenceId,
565+
delegation: {
566+
applied: true
567+
}
568+
}
569+
}
570+
}
571+
]
572+
},
573+
_count: {
574+
zip: true,
575+
country: true,
576+
_all: true
577+
}
578+
});
556579
return {
557580
countdowns,
558581
registrationStatistics,
559582
ageStatistics,
560583
diet,
561584
gender,
562-
status
585+
status,
586+
addresses
563587
};
564588
}

src/routes/(authenticated)/management/[conferenceId]/stats/+page.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
import DietMatrix from './widgets/DietMatrix.svelte';
2222
import GenderMatrix from './widgets/GenderMatrix.svelte';
2323
import Status from './widgets/Status.svelte';
24+
import Maps from './widgets/Maps.svelte';
25+
import { browser } from '$app/environment';
2426
let { data }: { data: PageData } = $props();
25-
2627
onMount(() => {
2728
// look for history in local storage
2829
const history: StatsTypeHistoryEntry[] = JSON.parse(
@@ -67,6 +68,7 @@
6768
<DietMatrix {data} />
6869
<GenderMatrix {data} />
6970
<Status {data} />
71+
<Maps addresses={data.stats.addresses} />
7072
<section class="card bg-base-200 col-span-2 shadow-sm md:col-span-12">
7173
<div class="card-body">
7274
<h3 class="text-xl font-bold">{m.historyComparison()}</h3>

src/routes/(authenticated)/management/[conferenceId]/stats/+page.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@ import { graphql } from '$houdini';
22
import { error } from '@sveltejs/kit';
33
import type { PageLoad } from './$types';
44

5+
export const ssr = false;
6+
57
const statsQuery = graphql(`
68
query ConferenceStatsQuery($conferenceID: ID!) {
79
getConferenceStatistics(conferenceId: $conferenceID) {
10+
addresses {
11+
country
12+
zip
13+
_count {
14+
zip
15+
country
16+
_all
17+
}
18+
}
819
age {
920
average
1021
distribution {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<script lang="ts">
2+
import { Map, TileLayer, Popup, Marker } from 'sveaflet';
3+
import { divIcon, point } from 'leaflet';
4+
import { MarkerCluster } from 'sveaflet-markercluster';
5+
import type { ConferenceStatsQuery$result } from '$houdini';
6+
import type { ZipCoordinate } from '../zip-api/+server';
7+
import { m } from '$lib/paraglide/messages';
8+
import { page } from '$app/state';
9+
10+
interface Props {
11+
addresses: ConferenceStatsQuery$result['getConferenceStatistics']['addresses'];
12+
}
13+
14+
type Coordinate = {
15+
zip: string;
16+
country: string;
17+
lat: number;
18+
lng: number;
19+
cached?: boolean;
20+
zipCount: number;
21+
};
22+
23+
let { addresses }: Props = $props();
24+
let coordinates: Coordinate[] = $state([]);
25+
26+
// fetch coordinates
27+
async function fetchCoordinates() {
28+
// call the relative endpoint that lives under the current stats route
29+
// (the endpoint file is at src/routes/(authenticated)/management/[conferenceId]/stats/zip-api/+server.ts)
30+
// Build an absolute URL from the current location so we always target the
31+
// stats folder (avoids browser relative-URL quirks when the path doesn't
32+
// end with a trailing slash). Example result: /management/:conferenceId/stats/zip-api
33+
const base = page.url.pathname.replace(/\/?$/, '/');
34+
const endpoint = base + 'zip-api';
35+
const res = await fetch(endpoint, {
36+
method: 'POST',
37+
headers: { 'Content-Type': 'application/json' },
38+
body: JSON.stringify(addresses.filter((a) => a.country === 'DEU'))
39+
});
40+
const data: ZipCoordinate[] = await res.json();
41+
return data
42+
.filter((item) => item.lat != null && item.lng != null)
43+
.map((item) => {
44+
const addr = addresses.find((a) => a.zip === item.zip && a.country === 'DEU');
45+
return {
46+
...item,
47+
lat: Number(item.lat),
48+
lng: Number(item.lng),
49+
zipCount: addr?._count.zip ?? 0,
50+
country: addr?.country ?? 'N/A'
51+
};
52+
})
53+
.filter((item) => item.country != 'N/A');
54+
}
55+
56+
$effect(() => {
57+
fetchCoordinates().then((data) => {
58+
coordinates = data;
59+
});
60+
});
61+
</script>
62+
63+
<section
64+
class="card bg-primary text-primary-content col-span-2 grow shadow-sm md:col-span-12 xl:col-span-6"
65+
>
66+
<div class="w-full h-[500px]">
67+
<Map options={{ center: [51.948, 10.2651], zoom: 6 }}>
68+
<TileLayer url={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'} />
69+
<!-- Marker Rendering -->
70+
<MarkerCluster
71+
options={{
72+
spiderLegPolylineOptions: {
73+
weight: 2,
74+
color: '#f00',
75+
opacity: 0.5
76+
},
77+
iconCreateFunction: (cluster) => {
78+
const markers = cluster.getAllChildMarkers();
79+
let count = 0;
80+
let icon_size = ' marker-cluster-';
81+
for (let i = 0; i < markers.length; i++) {
82+
count += markers[i].options.data.count;
83+
}
84+
if (count < 10) {
85+
icon_size += 'small';
86+
} else if (count < 50) {
87+
icon_size += 'medium';
88+
} else {
89+
icon_size += 'large';
90+
}
91+
return divIcon({
92+
html: `<div><span>${count}</span></div>`,
93+
className: `marker-cluster${icon_size}`,
94+
iconSize: point(40, 40)
95+
});
96+
}
97+
}}
98+
>
99+
{#each coordinates as item (`${item.country}_${item.zip}`)}
100+
{@const markerTitle = `${m.zipCode()}: ${item.zip} (${item.zipCount})`}
101+
<Marker
102+
latLng={[item.lat, item.lng]}
103+
options={{ title: markerTitle, data: { count: item.zipCount } }}
104+
>
105+
<Popup>
106+
<strong>{m.zipCode()}: {item.zip}</strong><br />
107+
{m.participants()}: {item.zipCount}
108+
</Popup>
109+
</Marker>
110+
{/each}
111+
</MarkerCluster>
112+
</Map>
113+
</div>
114+
</section>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { ConferenceStatsQuery$result } from '$houdini';
2+
import { parse } from 'csv-parse/sync';
3+
// This is hard coded for now
4+
// TODO If we expand to other countries than Germany this should be outsourced into an env variable.
5+
const CSV_URL =
6+
'https://raw.githubusercontent.com/WZBSocialScienceCenter/plz_geocoord/refs/heads/master/plz_geocoord.csv';
7+
8+
let zipMap: Map<string, { lat: number; lng: number }> | null = null;
9+
10+
async function loadZipMap() {
11+
if (zipMap) return zipMap; // Cache!
12+
try {
13+
const res = await fetch(CSV_URL);
14+
if (!res.ok) {
15+
throw new Error(`Failed to download ZIP CSV: ${res.status} ${res.statusText}`);
16+
}
17+
const csvText = await res.text();
18+
const records = parse(csvText, {
19+
columns: ['zip', 'lat', 'lng'],
20+
skip_empty_lines: true
21+
}) as { lat: string; lng: string; zip: string }[];
22+
23+
const map = new Map<string, { lat: number; lng: number }>();
24+
25+
for (const r of records) {
26+
if (r.zip && r.lat && r.lng) {
27+
map.set(r.zip.trim(), {
28+
lat: parseFloat(r.lat),
29+
lng: parseFloat(r.lng)
30+
});
31+
}
32+
}
33+
34+
zipMap = map; // Cache in memory
35+
return map;
36+
} catch (error) {
37+
console.error('Error loading ZIP map:', error);
38+
throw new Error('Unable to load geographic data. Please try again later.');
39+
}
40+
}
41+
42+
export interface ZipCoordinate {
43+
lat?: number;
44+
lng?: number;
45+
zip: string;
46+
}
47+
48+
// Function for multiple Zips
49+
function getCoordinatesForZips(
50+
zips: string[],
51+
map: Map<string, { lat: number; lng: number }>
52+
): ZipCoordinate[] {
53+
return zips.map((zip) => {
54+
const coords = map.get(zip.trim());
55+
if (!coords) return { zip };
56+
return { zip, ...coords };
57+
});
58+
}
59+
60+
// POST-Handler
61+
export const POST = async ({ request }) => {
62+
const body: ConferenceStatsQuery$result['getConferenceStatistics']['addresses'] =
63+
await request.json();
64+
const zips = body
65+
.map((item: { zip: string | null }) => item.zip)
66+
.filter((zip): zip is string => typeof zip === 'string' && zip.trim() !== '');
67+
68+
const map = await loadZipMap();
69+
70+
const results = getCoordinatesForZips(zips, map);
71+
72+
return new Response(JSON.stringify(results), { status: 200 });
73+
};

0 commit comments

Comments
 (0)