// 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 &&
}
{beforeLabel}
);
}
/* ─────────────────────────────────────────
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 &&
}
);
}
/* ─────────────────────────────────────────
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 (
{/* 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 }));
})();