feature: chemnitz map

This commit is contained in:
Benno Tielen 2026-04-23 17:43:38 +02:00
parent 3e836bb016
commit c0fe32f9f5
8 changed files with 431 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { ChemnitzMap } from './ChemnitzMap'
const meta: Meta<typeof ChemnitzMap> = {
component: ChemnitzMap,
decorators: [
(Story) => (
<div style={{ width: '100%', maxWidth: 1100, margin: '0 auto' }}>
<Story />
</div>
),
],
}
type Story = StoryObj<typeof ChemnitzMap>
export default meta
export const Default: Story = {}
export const WithClickHandler: Story = {
args: {
onChurchClick: (church) => {
// eslint-disable-next-line no-alert
alert(`Clicked: ${church.replace(/_/g, ' ')}`)
},
},
}
export const CustomUrls: Story = {
args: {
churchUrls: {
'St._Marien': '/custom/marien',
'St._Joseph': '/custom/joseph',
},
onChurchClick: (church) => {
// eslint-disable-next-line no-console
console.log('Would navigate for', church)
},
},
}

View file

@ -0,0 +1,92 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import classNames from 'classnames'
import styles from './styles.module.scss'
import { chemnitzMapSvg } from './chemnitzMapSvg'
export type ChemnitzMapChurch =
| 'St._Antonius_Frankenberg'
| 'St._Marien'
| 'St._Joseph'
| 'St._Johannes_Nepomuk'
| 'St._Antonius'
| 'St._Franziskus'
| 'Maria_Hilfe_der_Chrsiten'
const DEFAULT_CHURCH_URLS: Record<ChemnitzMapChurch, string> = {
'St._Antonius_Frankenberg': '/gemeinde/st-antonius-frankenberg',
'St._Marien': '/gemeinde/st-marien',
'St._Joseph': '/gemeinde/st-joseph',
'St._Johannes_Nepomuk': '/gemeinde/st-johannes-nepomuk',
'St._Antonius': '/gemeinde/st-antonius',
'St._Franziskus': '/gemeinde/st-franziskus',
'Maria_Hilfe_der_Chrsiten': '/gemeinde/maria-hilfe-der-christen',
}
const CHURCH_IDS = Object.keys(DEFAULT_CHURCH_URLS) as ChemnitzMapChurch[]
export type ChemnitzMapProps = {
churchUrls?: Partial<Record<ChemnitzMapChurch, string>>
onChurchClick?: (church: ChemnitzMapChurch) => void
fill?: boolean
}
export const ChemnitzMap = ({ churchUrls, onChurchClick, fill = false }: ChemnitzMapProps) => {
const router = useRouter()
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const urls = { ...DEFAULT_CHURCH_URLS, ...churchUrls }
const cleanups: Array<() => void> = []
for (const id of CHURCH_IDS) {
const group = root.querySelector<SVGGElement>(`g[id="${id}"]`)
if (!group) continue
group.classList.add(styles.church)
group.setAttribute('role', 'link')
group.setAttribute('tabindex', '0')
group.setAttribute('aria-label', id.replace(/_/g, ' '))
const activate = () => {
if (onChurchClick) {
onChurchClick(id)
return
}
const href = urls[id]
if (href) router.push(href)
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
activate()
}
}
group.addEventListener('click', activate)
group.addEventListener('keydown', onKeyDown)
cleanups.push(() => {
group.removeEventListener('click', activate)
group.removeEventListener('keydown', onKeyDown)
})
}
return () => cleanups.forEach((fn) => fn())
}, [router, churchUrls, onChurchClick])
return (
<div
ref={rootRef}
className={classNames(styles.map, { [styles.fill]: fill })}
dangerouslySetInnerHTML={{ __html: chemnitzMapSvg }}
/>
)
}
export default ChemnitzMap

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 103 KiB

View file

@ -0,0 +1,45 @@
.map {
width: 100%;
:global(svg) {
width: 100%;
height: auto;
display: block;
}
}
.fill {
height: 100%;
:global(svg) {
width: 100%;
height: 100%;
}
}
.church {
cursor: pointer;
transform-box: fill-box;
transform-origin: center;
transition: transform 0.3s ease;
:global(path.cls-1) {
transition: fill 0.3s ease;
}
&:hover {
transform: scale(1.15);
:global(path.cls-1) {
fill: #0f6898;
}
}
&:focus-visible {
outline: none;
:global(path.cls-1) {
fill: #0f6898;
}
}
}

View file

@ -0,0 +1,38 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { CollapsibleMapWithText } from './CollapsibleMapWithText'
import { Title } from '@/components/Title/Title'
import { P } from '@/components/Text/Paragraph'
const meta: Meta<typeof CollapsibleMapWithText> = {
component: CollapsibleMapWithText,
}
type Story = StoryObj<typeof CollapsibleMapWithText>
export default meta
export const OurParishes: Story = {
args: {
backgroundColor: 'soft',
schema: 'base',
title: 'Unsere Gemeinden',
text:
'Unsere Pfarrei umfasst sieben Kirchen in und um Chemnitz. Klicken Sie auf eine Kirche, um mehr über die jeweilige Gemeinde zu erfahren von Gottesdienstzeiten über Gruppen und Kreise bis hin zu Kontaktmöglichkeiten.\n' +
'\n' +
'Jede Gemeinde hat ihren eigenen Charakter und ihre eigene Geschichte, gemeinsam bilden sie ein lebendiges katholisches Netzwerk in der Region.',
onChurchClick: (church) => {
// eslint-disable-next-line no-alert
alert(`Klick: ${church.replace(/_/g, ' ')}`)
},
content: (
<>
<Title title={'Über unsere Pfarrei'} size={'md'} />
<P width={'3/4'}>
Die Pfarrei wurde gegründet, um die pastorale Arbeit der umliegenden Kirchgemeinden
zu bündeln und gleichzeitig den lokalen Charakter jeder einzelnen Gemeinde zu
bewahren. Auf der Karte sehen Sie die geografische Verteilung von St. Marien im
Stadtzentrum bis St. Antonius in Frankenberg.
</P>
</>
),
},
}

View file

@ -0,0 +1,123 @@
'use client'
import { BackgroundColor, Section } from '@/components/Section/Section'
import { Title } from '@/components/Title/Title'
import { Container } from '@/components/Container/Container'
import { TextDiv } from '@/components/Text/TextDiv'
import { Row } from '@/components/Flex/Row'
import { CollapsibleArrow } from '@/components/CollapsibleArrow/CollapsibleArrow'
import { ChemnitzMap, ChemnitzMapProps } from '@/components/ChemnitzMap/ChemnitzMap'
import React, { useEffect, useRef, useState } from 'react'
import classNames from 'classnames'
import styles from './styles.module.scss'
type CollapsibleMapWithTextProps = {
backgroundColor?: BackgroundColor
title: string
text: string
schema?: 'base' | 'contrast'
content: React.ReactNode
churchUrls?: ChemnitzMapProps['churchUrls']
onChurchClick?: ChemnitzMapProps['onChurchClick']
}
type MoreInformationProps = {
isCollapsed: boolean
onClick: () => void
}
const MoreInformation = ({ isCollapsed, onClick }: MoreInformationProps) => {
const [direction, setDirection] = useState<'UP' | 'DOWN'>(isCollapsed ? 'DOWN' : 'UP')
const toggleDirection = () => {
setDirection(direction === 'UP' ? 'DOWN' : 'UP')
}
const handleClick = () => {
toggleDirection()
onClick()
}
return (
<button
onClick={handleClick}
className={styles.more}
onMouseEnter={toggleDirection}
onMouseLeave={toggleDirection}
>
Mehr erfahren <CollapsibleArrow direction={direction} />
</button>
)
}
export const CollapsibleMapWithText = ({
backgroundColor,
title,
text,
schema = 'base',
content,
churchUrls,
onChurchClick,
}: CollapsibleMapWithTextProps) => {
const ref = useRef<HTMLDivElement>(null)
const ref2 = useRef<HTMLDivElement>(null)
const [contentHeight, setContentHeight] = useState(0)
const [isCollapsed, setIsCollapsed] = useState(true)
const collapse = () => {
setIsCollapsed(true)
ref.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
if (ref2.current) {
setContentHeight(ref2.current.scrollHeight)
}
}, [ref2])
return (
<div ref={ref} className={styles.root}>
<Section backgroundColor={backgroundColor}>
<Container>
<Row alignItems={'center'}>
<div className={classNames(styles.col, styles.imageCol)} />
<div className={styles.col}>
<Title title={title} size={'lg'} color={schema} />
<TextDiv text={text} />
<div className={styles.right}>
<MoreInformation
isCollapsed={isCollapsed}
onClick={() => setIsCollapsed(!isCollapsed)}
/>
</div>
</div>
</Row>
</Container>
</Section>
<div className={styles.mapCorner}>
<ChemnitzMap churchUrls={churchUrls} onChurchClick={onChurchClick} />
</div>
<div
ref={ref2}
className={styles.content}
style={{ maxHeight: isCollapsed ? undefined : contentHeight }}
>
<Section
backgroundColor={backgroundColor}
padding={'small'}
paddingBottom={'large'}
>
<Container>{content}</Container>
<div className={styles.endButton}>
<CollapsibleArrow direction={'UP'} onClick={collapse} />
</div>
</Section>
</div>
</div>
)
}

View file

@ -0,0 +1,90 @@
@import "template.scss";
.root {
position: relative;
}
.mapCorner {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 0;
pointer-events: none;
opacity: 0.5;
:global(svg) {
pointer-events: auto;
}
}
.right {
margin-top: 40px;
text-align: right;
z-index: 3;
}
.image {
border-radius: 13px;
transition: opacity 3s;
width: 100%;
height: 100%;
}
.col {
width: calc(50% - 40px);
}
.imageCol {
/* Empty column — space reserved for the map positioned in the upper-left corner. */
}
.more {
font-size: 18px;
color: $base-color;
cursor: pointer;
font-weight: bold;
display: inline-flex;
gap: 10px;
border: 0;
background-color: inherit;
align-items: center;
}
.content {
transition: max-height 500ms ease-in;
max-height: 0;
overflow: hidden;
}
.endButton {
position: relative;
top: 75px;
text-align: center;
}
.endButton svg {
cursor: pointer;
}
@media screen and (max-width: 750px) {
.imageCol {
display: none;
}
.col {
width: 100%;
}
.mapCorner {
position: static;
width: 70vw;
margin: 0 auto 40px;
}
}
@media screen and (max-width: 576px) {
.endButton {
top: 20px;
}
}