// Quick City — Shared utilities (animated counter, scroll reveal, map, before/after) /* ───────────────────────────────────────── useInView — fire once when element enters viewport ───────────────────────────────────────── */ function useInView(ref, options = {}) { const [inView, setInView] = React.useState(false); React.useEffect(() => { if (!ref.current) return; const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { setInView(true); obs.disconnect(); } }, { threshold: 0.2, ...options }); obs.observe(ref.current); return () => obs.disconnect(); }, []); return inView; } /* ───────────────────────────────────────── AnimatedNumber — counts up from 0 to target when scrolled into view Props: value (string like "150+", "24/7", "8.400", "47", "100%"), duration (ms) ───────────────────────────────────────── */ function AnimatedNumber({ value, duration = 1600, className, style }) { const ref = React.useRef(null); const inView = useInView(ref); const [display, setDisplay] = React.useState(value); // Parse numeric part + prefix/suffix const parsed = React.useMemo(() => { const str = String(value); // Match leading non-numeric, the number (with dots/commas as thousand sep), trailing const m = str.match(/^([^\d-]*)([\d.,]+)(.*)$/); if (!m) return { animate: false, str }; const prefix = m[1] || ''; const numStr = m[2]; const suffix = m[3] || ''; // Detect if it uses thousand-separators (dots/commas) const usesDotSep = /\d\.\d{3}(\D|$)/.test(numStr); const usesCommaSep = /\d,\d{3}(\D|$)/.test(numStr); const cleanedForParse = usesDotSep ? numStr.replace(/\./g, '') : usesCommaSep ? numStr.replace(/,/g, '') : numStr.replace(',', '.'); const num = parseFloat(cleanedForParse); if (isNaN(num)) return { animate: false, str }; return { animate: true, num, prefix, suffix, usesDotSep, usesCommaSep, original: str }; }, [value]); React.useEffect(() => { if (!inView || !parsed.animate) { setDisplay(value); return; } let raf; const start = performance.now(); const target = parsed.num; const isInteger = Number.isInteger(target); const tick = (now) => { const t = Math.min(1, (now - start) / duration); // ease-out cubic const eased = 1 - Math.pow(1 - t, 3); const current = target * eased; let numFormatted; if (isInteger) { numFormatted = Math.round(current).toString(); if (parsed.usesDotSep) { numFormatted = numFormatted.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); } else if (parsed.usesCommaSep) { numFormatted = numFormatted.replace(/\B(?=(\d{3})+(?!\d))/g, ','); } } else { numFormatted = current.toFixed(1).replace('.', parsed.usesCommaSep ? ',' : '.'); } setDisplay(parsed.prefix + numFormatted + parsed.suffix); if (t < 1) raf = requestAnimationFrame(tick); else setDisplay(parsed.original); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, value]); return {display}; } /* ───────────────────────────────────────── Reveal — wraps children; adds .is-visible class when scrolled into view Use with .reveal CSS class for opacity/translate transition. ───────────────────────────────────────── */ function Reveal({ children, delay = 0, as: Tag = 'div', className = '', ...rest }) { const ref = React.useRef(null); const inView = useInView(ref); return ( {children} ); } /* ───────────────────────────────────────── BeforeAfter — slider revealing two images Props: beforeSrc, afterSrc, beforeLabel?, afterLabel? ───────────────────────────────────────── */ function BeforeAfter({ beforeSrc, afterSrc, beforeLabel = 'Vorher', afterLabel = 'Nachher', alt = '' }) { const containerRef = React.useRef(null); const beforeWrapRef = React.useRef(null); const afterWrapRef = React.useRef(null); const handleRef = React.useRef(null); const draggingRef = React.useRef(false); const posRef = React.useRef(50); const rafRef = React.useRef(null); const [bothLoaded, setBothLoaded] = React.useState(false); const loadedCountRef = React.useRef(0); // Preload both images before rendering — avoids brief flash of after-only React.useEffect(() => { setBothLoaded(false); loadedCountRef.current = 0; const onOne = () => { loadedCountRef.current += 1; if (loadedCountRef.current >= 2) setBothLoaded(true); }; const i1 = new Image(); const i2 = new Image(); i1.onload = onOne; i1.onerror = onOne; i1.src = beforeSrc; i2.onload = onOne; i2.onerror = onOne; i2.src = afterSrc; // Fallback after 1.5s in case load events don't fire const t = setTimeout(() => setBothLoaded(true), 1500); return () => { clearTimeout(t); i1.onload = null; i1.onerror = null; i2.onload = null; i2.onerror = null; }; }, [beforeSrc, afterSrc]); const updatePos = React.useCallback((pct) => { posRef.current = pct; if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = null; if (beforeWrapRef.current) { beforeWrapRef.current.style.clipPath = `inset(0 ${100 - posRef.current}% 0 0)`; } if (afterWrapRef.current) { afterWrapRef.current.style.clipPath = `inset(0 0 0 ${posRef.current}%)`; } if (handleRef.current) { handleRef.current.style.left = posRef.current + '%'; } }); }, []); const updateFromEvent = React.useCallback((clientX) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const x = clientX - rect.left; const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); updatePos(pct); }, [updatePos]); React.useEffect(() => { const onMove = (e) => { if (!draggingRef.current) return; const clientX = e.touches ? e.touches[0].clientX : e.clientX; updateFromEvent(clientX); if (e.touches) e.preventDefault(); }; const onUp = () => { draggingRef.current = false; }; window.addEventListener('mousemove', onMove, { passive: true }); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('mouseup', onUp); window.addEventListener('touchend', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('touchmove', onMove); window.removeEventListener('mouseup', onUp); window.removeEventListener('touchend', onUp); if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [updateFromEvent]); return (
{ if (!bothLoaded) return; draggingRef.current = true; updateFromEvent(e.clientX); }} onTouchStart={(e) => { if (!bothLoaded) return; draggingRef.current = true; updateFromEvent(e.touches[0].clientX); }} > {!bothLoaded &&
} {alt}
{alt}
{beforeLabel}
{afterLabel}
); } /* ───────────────────────────────────────── GermanyMap — schematic SVG map with pins Props: pins = [{ x, y, label, highlight }], viewBox optional ───────────────────────────────────────── */ /* ───────────────────────────────────────── GermanyMap — D3-geo with Bundesländer outlines + country border ───────────────────────────────────────── */ const GERMANY_VIEWBOX = { w: 100, h: 130 }; let _germanyDataCache = null; async function loadGermanyData() { if (_germanyDataCache) return _germanyDataCache; const out = { country: null, states: null }; // 1. Country outline (from world-atlas) try { const resp = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json'); const topo = await resp.json(); const all = window.topojson.feature(topo, topo.objects.countries); out.country = all.features.find(f => f.id === '276'); // ISO 276 = Germany } catch (e) { console.warn('country geo load failed', e); } // 2. Bundesländer (isellsoap/deutschlandGeoJSON via jsdelivr) try { const resp = await fetch('https://cdn.jsdelivr.net/gh/isellsoap/deutschlandGeoJSON@main/2_bundeslaender/3_mittel.geo.json'); const states = await resp.json(); out.states = states; } catch (e) { console.warn('Bundesländer geo load failed', e); } // Fallback if everything fails if (!out.country && !out.states) out.country = window.QC_GERMANY_GEOJSON; _germanyDataCache = out; return out; } function GermanyMap({ pins = [], hubIdx = null, title, lead }) { const [data, setData] = React.useState(_germanyDataCache); React.useEffect(() => { if (data) return; let cancelled = false; loadGermanyData().then(d => { if (!cancelled) setData(d); }); return () => { cancelled = true; }; }, []); const projected = React.useMemo(() => { if (typeof window === 'undefined' || !window.d3 || !data) return null; const refGeo = data.states || data.country; if (!refGeo) return null; const w = GERMANY_VIEWBOX.w, h = GERMANY_VIEWBOX.h; const pad = 4; const proj = window.d3.geoMercator().fitExtent( [[pad, pad], [w - pad, h - pad]], refGeo ); const path = window.d3.geoPath(proj); const countryPath = data.country ? path(data.country) : null; const statePaths = data.states && data.states.features ? data.states.features.map(f => path(f)) : null; const points = pins.map(p => { if (typeof p.lat === 'number' && typeof p.lng === 'number') { const r = proj([p.lng, p.lat]) || [0, 0]; return { x: r[0], y: r[1], label: p.label, labelPos: p.labelPos }; } return p; }); return { countryPath, statePaths, points }; }, [pins, data]); return (
{title &&

{title}

{lead &&

{lead}

}
}
); } /* ───────────────────────────────────────── GermanyClusterMap — clean map (no labels) + interactive legend list ───────────────────────────────────────── */ const GERMANY_VIEWBOX_CLUSTER = { w: 110, h: 130, projRight: 92 }; function GermanyClusterMap({ clusters = [], title, lead }) { const { lang } = (typeof useT === 'function') ? useT() : { lang: 'de' }; const [data, setData] = React.useState(_germanyDataCache); const [hoverId, setHoverId] = React.useState(null); // Translatable UI strings + region descriptors (city names stay as proper nouns) const TX = { de: { hub: 'HUB', zentrale: 'Bernau Zentrale', sites: 'Einsatzorte', regions: 'Regionen', more: 'weitere', hint: 'Bewegen Sie den Mauszeiger über einen Punkt' }, en: { hub: 'HUB', zentrale: 'Bernau HQ', sites: 'sites', regions: 'regions', more: 'more', hint: 'Hover over a point for details' }, tr: { hub: 'MERKEZ', zentrale: 'Bernau Merkez', sites: 'saha', regions: 'bölge', more: 'daha', hint: 'Ayrıntılar için bir noktanın üzerine gelin' } }; const REGIONS = { 'Brandenburg · Hub': { en: 'Brandenburg · Hub', tr: 'Brandenburg · Merkez' }, 'Hauptstadtregion': { en: 'Capital region', tr: 'Başkent bölgesi' }, 'Süd-Brandenburg': { en: 'Southern Brandenburg', tr: 'Güney Brandenburg' }, 'Prignitz & Uckermark': { en: 'Prignitz & Uckermark', tr: 'Prignitz & Uckermark' }, 'Hamburg': { en: 'Hamburg', tr: 'Hamburg' }, 'Ostholstein': { en: 'East Holstein', tr: 'Doğu Holstein' }, 'Westküste & Kieler Förde': { en: 'West coast & Kiel Fjord', tr: 'Batı kıyısı & Kiel Körfezi' }, 'Schwerin & Ostsee': { en: 'Schwerin & Baltic Sea', tr: 'Schwerin & Baltık' }, 'Sachsen-Anhalt': { en: 'Saxony-Anhalt', tr: 'Saksonya-Anhalt' }, 'Sachsen': { en: 'Saxony', tr: 'Saksonya' } }; const L = TX[lang] || TX.de; const trRegion = (r) => (lang === 'de' ? r : (REGIONS[r] && REGIONS[r][lang]) || r); const NAMES = { 'Berlin & Umland': { en: 'Berlin & surroundings', tr: 'Berlin & çevresi' }, 'Spreewald & Niederlausitz': { en: 'Spreewald & Lower Lusatia', tr: 'Spreewald & Aşağı Lusatia' }, 'Nord-Brandenburg': { en: 'Northern Brandenburg', tr: 'Kuzey Brandenburg' }, 'Hamburg & Umland': { en: 'Hamburg & surroundings', tr: 'Hamburg & çevresi' }, 'Lübeck-Bucht': { en: 'Bay of Lübeck', tr: 'Lübeck Körfezi' }, 'Schleswig-Holstein': { en: 'Schleswig-Holstein', tr: 'Schleswig-Holstein' }, 'Mecklenburg-Vorpommern': { en: 'Mecklenburg-Vorpommern', tr: 'Mecklenburg-Vorpommern' }, 'Saale-Unstrut': { en: 'Saale-Unstrut', tr: 'Saale-Unstrut' }, 'Dresden': { en: 'Dresden', tr: 'Dresden' } }; const trName = (n) => (lang === 'de' ? n : (NAMES[n] && NAMES[n][lang]) || n); React.useEffect(() => { if (data) return; let cancelled = false; loadGermanyData().then(d => { if (!cancelled) setData(d); }); return () => { cancelled = true; }; }, []); const hubIdx = clusters.findIndex(c => c.hub); const projected = React.useMemo(() => { if (typeof window === 'undefined' || !window.d3 || !data) return null; const refGeo = data.states || data.country; if (!refGeo) return null; const w = GERMANY_VIEWBOX_CLUSTER.w, h = GERMANY_VIEWBOX_CLUSTER.h; const pad = 4; // Constrain country to the LEFT part of the viewBox, leaving room on the right for labels const proj = window.d3.geoMercator().fitExtent( [[pad, pad], [GERMANY_VIEWBOX_CLUSTER.projRight - pad, h - pad]], refGeo ); const path = window.d3.geoPath(proj); const countryPath = data.country ? path(data.country) : null; const statePaths = data.states && data.states.features ? data.states.features.map(f => path(f)) : null; const points = clusters.map(c => { const r = proj([c.lng, c.lat]) || [0, 0]; return { ...c, x: r[0], y: r[1] }; }); return { countryPath, statePaths, points }; }, [clusters, data]); const totalCities = clusters.reduce((s, c) => s + c.cities.length, 0); const hovered = hoverId ? clusters.find(c => c.id === hoverId) : null; return (
{projected ? ( <> {projected.statePaths && projected.statePaths.map((d, i) => ( ))} {projected.countryPath && ( )} {/* Hub pulse — anchored at actual hub position */} {hubIdx >= 0 && projected.points[hubIdx] && ( )} {/* Connections from hub to clusters — anchored at actual hub */} {hubIdx >= 0 && projected.points[hubIdx] && projected.points.map((p, i) => { if (i === hubIdx) return null; const isActive = hoverId === p.id; return ( ); })} {/* Cluster pins — non-hub first */} {projected.points.map((p, i) => { if (p.hub) return null; const count = p.cities.length; // Cap max radius at 3.8 so Berlin's 19-circle doesn't dominate const baseR = Math.max(1.4, Math.min(3.8, 1.0 + Math.sqrt(count) * 0.75)); const isActive = hoverId === p.id; const r = isActive ? baseR + 1.0 : baseR; const dimmed = hoverId && !isActive; return ( setHoverId(p.id)} onMouseLeave={() => setHoverId(null)}> {count} ); })} {/* Hub marker — rendered LAST so it sits on top */} {projected.points.map((p, i) => { if (!p.hub) return null; const isActive = hoverId === p.id; const dimmed = hoverId && !isActive; // Offset marker NE so it sits clearly outside Berlin's cluster circle const offX = 8; const offY = -7; const dx = p.x + offX; const dy = p.y + offY; const r = isActive ? 2.0 : 1.4; const tx = dx + r + 1.4; return ( setHoverId(p.id)} onMouseLeave={() => setHoverId(null)}> {/* Leader line from real Bernau position to offset marker */} {/* Real-position indicator dot (tiny dot at actual Bernau) */} {/* Offset hub marker */} {/* Label to the right of marker — simple white text */} {L.zentrale} ); })} ) : ( Karte lädt … )} {/* Floating info panel — replaces the legend list */}
{hovered ? ( <>
{!hovered.hub && {hovered.cities.length}} {trRegion(hovered.region)} {hovered.hub && {L.hub}}
{trName(hovered.name)}
{hovered.cities.length > 1 && (
{hovered.cities.slice(0, 12).join(' · ')} {hovered.cities.length > 12 ? ` · +${hovered.cities.length - 12} ${L.more}` : ''}
)} ) : (
{totalCities}+{L.sites}
{clusters.length}{L.regions}
{L.hint}
)}
); } Object.assign(window, { useInView, AnimatedNumber, Reveal, BeforeAfter, GermanyMap, GermanyClusterMap }); /* ───────────────────────────────────────── Auto-reveal: scan DOM for targeted elements, fade them in on scroll. Set --i per child for stagger; toggle .qc-reveal-on on parent when visible. ───────────────────────────────────────── */ (function setupAutoReveal() { if (typeof window === 'undefined') return; if (window.__qcAutoReveal) return; window.__qcAutoReveal = true; // Selectors of "container" elements — we add .qc-reveal-on once they enter view const containerSelectors = [ '.hsec', '.hh', '.hpartner', '.hsvc-grid', '.svc-grid', '.prj-grid', '.hzert', '.hzert__seals', '.hvals', '.ab-narrative', '.ab-stats-box', '.prj-featured', '.prj-hero', '.page-head', '.hero', '.section', '.svchero', '.svc2-hero', '.svc2-intro', '.svc2-scope', '.svc2-process' ].join(','); function applyStagger(container) { // For grids, assign --i to immediate children const stagger = ['.hsvc-grid', '.svc-grid', '.prj-grid', '.hzert__seals', '.hpartner__stats', '.hvals', '.ab-stats-grid']; stagger.forEach(sel => { if (container.matches && container.matches(sel)) { Array.from(container.children).forEach((c, i) => c.style.setProperty('--i', i)); } else { container.querySelectorAll(sel).forEach(grid => { Array.from(grid.children).forEach((c, i) => c.style.setProperty('--i', i)); }); } }); } function observe(root = document) { const els = root.querySelectorAll(containerSelectors); if (!els.length) return; const io = new IntersectionObserver((entries) => { entries.forEach(en => { if (en.isIntersecting) { en.target.classList.add('qc-reveal-on'); applyStagger(en.target); io.unobserve(en.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' }); els.forEach(el => io.observe(el)); } // Re-scan whenever React re-renders something. Use MutationObserver as a safety net. let scheduled = false; function scheduleScan() { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; observe(); }); } document.addEventListener('DOMContentLoaded', scheduleScan); setTimeout(scheduleScan, 0); setTimeout(scheduleScan, 500); setTimeout(scheduleScan, 1500); // Watch the body for new content (route changes) const mo = new MutationObserver(() => scheduleScan()); if (document.body) mo.observe(document.body, { childList: true, subtree: true }); else document.addEventListener('DOMContentLoaded', () => mo.observe(document.body, { childList: true, subtree: true })); })();