diff --git a/public/astronomical_conversions.php b/public/astronomical_conversions.php index 8ea5281..43ee0c5 100644 --- a/public/astronomical_conversions.php +++ b/public/astronomical_conversions.php @@ -357,23 +357,73 @@ function h(?string $value): string unix: document.getElementById('timeUnix') }; - const unitToKm = { - km: 1, - au: KM_PER_AU, - lm: KM_PER_LIGHT_MINUTE, - lh: KM_PER_LIGHT_HOUR, - ly: KM_PER_LY, - pc: KM_PER_PC, - mpc: KM_PER_MPC + const DECIMAL_DIVISION_PRECISION = 32; + const pow10Cache = [1n]; + const decimalConstants = { + zero: null, + one: null, + hundred: null, + thousand: null, + million: null, + speedOfLightKmS: null, + secondsPerGyr: null, + mpcInKm: null, + kmPerAu: null, + kmPerLightMinute: null, + kmPerLightHour: null, + kmPerLy: null, + kmPerPc: null, + kmPerMpc: null, + jdUnixEpoch: null, + mjdOffset: null, + msPerDay: null, + msPerSecond: null, }; - const germanNumberFormatter = new Intl.NumberFormat('de-DE', { - maximumFractionDigits: 10 - }); + const getPow10 = function (exponent) { + for (let i = pow10Cache.length; i <= exponent; i += 1) { + pow10Cache[i] = pow10Cache[i - 1] * 10n; + } - const parseGermanNumber = function (value) { + return pow10Cache[exponent]; + }; + + const normalizeDecimal = function (decimal) { + let digits = decimal.digits; + let scale = decimal.scale; + + if (digits === 0n) { + return { sign: 1, digits: 0n, scale: 0 }; + } + + while (scale > 0 && digits % 10n === 0n) { + digits /= 10n; + scale -= 1; + } + + return { + sign: decimal.sign < 0 ? -1 : 1, + digits, + scale + }; + }; + + const decimalFromParts = function (sign, digits, scale) { + return normalizeDecimal({ + sign: sign < 0 ? -1 : 1, + digits: digits < 0n ? -digits : digits, + scale: Math.max(0, scale | 0) + }); + }; + + const decimalFromInteger = function (value) { + const bigintValue = typeof value === 'bigint' ? value : BigInt(value); + return decimalFromParts(bigintValue < 0n ? -1 : 1, bigintValue < 0n ? -bigintValue : bigintValue, 0); + }; + + const parseDecimalInput = function (value) { if (typeof value !== 'string') { - return Number.NaN; + return null; } const normalized = value @@ -383,51 +433,266 @@ function h(?string $value): string .replace(',', '.'); if (normalized === '') { - return Number.NaN; + return null; } - return Number(normalized); + const match = normalized.match(/^([+-])?(\d+)(?:\.(\d+))?$/); + if (!match) { + return null; + } + + const sign = match[1] === '-' ? -1 : 1; + const integerPart = match[2]; + const fractionalPart = match[3] || ''; + return decimalFromParts(sign, BigInt(integerPart + fractionalPart), fractionalPart.length); }; - const formatValue = function (value) { + const parseDecimalConstant = function (value) { + const normalized = String(value).trim(); + const match = normalized.match(/^([+-])?(\d+)(?:\.(\d+))?$/); + if (!match) { + return null; + } + + const sign = match[1] === '-' ? -1 : 1; + const integerPart = match[2]; + const fractionalPart = match[3] || ''; + return decimalFromParts(sign, BigInt(integerPart + fractionalPart), fractionalPart.length); + }; + + const decimalToEnglishString = function (decimal) { + const normalized = normalizeDecimal(decimal); + const digitsText = normalized.digits.toString(); + const signText = normalized.sign < 0 && normalized.digits !== 0n ? '-' : ''; + + if (normalized.scale === 0) { + return signText + digitsText; + } + + const splitIndex = digitsText.length - normalized.scale; + if (splitIndex > 0) { + return signText + digitsText.slice(0, splitIndex) + '.' + digitsText.slice(splitIndex); + } + + return signText + '0.' + '0'.repeat(-splitIndex) + digitsText; + }; + + const decimalToNumber = function (decimal) { + return Number(decimalToEnglishString(decimal)); + }; + + const decimalFromNumber = function (value, fractionDigits) { if (!Number.isFinite(value)) { + return null; + } + + const digits = Math.max(0, Number(fractionDigits) || 0); + return parseDecimalConstant(Number(value).toFixed(digits)); + }; + + const decimalIsZero = function (decimal) { + return normalizeDecimal(decimal).digits === 0n; + }; + + const decimalAdd = function (left, right) { + const scale = Math.max(left.scale, right.scale); + const leftDigits = BigInt(left.sign) * left.digits * getPow10(scale - left.scale); + const rightDigits = BigInt(right.sign) * right.digits * getPow10(scale - right.scale); + const result = leftDigits + rightDigits; + + return decimalFromParts(result < 0n ? -1 : 1, result < 0n ? -result : result, scale); + }; + + const decimalSubtract = function (left, right) { + return decimalAdd(left, { + sign: right.sign * -1, + digits: right.digits, + scale: right.scale + }); + }; + + const decimalMultiply = function (left, right) { + return decimalFromParts(left.sign * right.sign, left.digits * right.digits, left.scale + right.scale); + }; + + const decimalDivide = function (left, right, precision) { + if (decimalIsZero(right)) { + return null; + } + + const digits = Math.max(0, precision | 0); + const numerator = left.digits * getPow10(digits + right.scale); + const denominator = right.digits * getPow10(left.scale); + const quotient = numerator / denominator; + + return decimalFromParts(left.sign * right.sign, quotient, digits); + }; + + const decimalSquare = function (value) { + return decimalMultiply(value, value); + }; + + const decimalRoundToBigInt = function (decimal) { + const normalized = normalizeDecimal(decimal); + if (normalized.scale === 0) { + return BigInt(normalized.sign) * normalized.digits; + } + + const divisor = getPow10(normalized.scale); + let integerPart = normalized.digits / divisor; + const remainder = normalized.digits % divisor; + + if (remainder * 2n >= divisor) { + integerPart += 1n; + } + + return BigInt(normalized.sign) * integerPart; + }; + + const formatDecimal = function (decimal, maxFractionDigits) { + if (!decimal) { return ''; } - if (value === 0) { - return '0'; + const normalized = normalizeDecimal(decimal); + const limit = Math.max(0, Number(maxFractionDigits) || 0); + const sign = normalized.sign < 0 && normalized.digits !== 0n ? '-' : ''; + const scale = Math.min(normalized.scale, limit); + const truncatedDigits = normalized.scale > scale + ? normalized.digits / getPow10(normalized.scale - scale) + : normalized.digits; + const digitsText = truncatedDigits.toString(); + + let integerPart = ''; + let fractionalPart = ''; + + if (scale === 0) { + integerPart = digitsText; + } else { + const splitIndex = digitsText.length - scale; + if (splitIndex > 0) { + integerPart = digitsText.slice(0, splitIndex); + fractionalPart = digitsText.slice(splitIndex); + } else { + integerPart = '0'; + fractionalPart = '0'.repeat(-splitIndex) + digitsText; + } } - return germanNumberFormatter.format(value); + integerPart = integerPart.replace(/^0+(?=\d)/, '') || '0'; + integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); + fractionalPart = fractionalPart.replace(/0+$/, ''); + + return fractionalPart !== '' + ? `${sign}${integerPart},${fractionalPart}` + : `${sign}${integerPart}`; + }; + + const convertDistanceDecimal = function (value, sourceUnit, targetUnit) { + if (!value) { + return null; + } + + if (sourceUnit === targetUnit) { + return value; + } + + if (sourceUnit === 'mpc' && targetUnit === 'pc') { + return decimalMultiply(value, decimalConstants.million); + } + + if (sourceUnit === 'pc' && targetUnit === 'mpc') { + return decimalDivide(value, decimalConstants.million, DECIMAL_DIVISION_PRECISION); + } + + const decimalUnitToKm = { + km: decimalFromInteger(1n), + au: decimalConstants.kmPerAu, + lm: decimalConstants.kmPerLightMinute, + lh: decimalConstants.kmPerLightHour, + ly: decimalConstants.kmPerLy, + pc: decimalConstants.kmPerPc, + mpc: decimalConstants.kmPerMpc + }; + + const valueInKm = decimalMultiply(value, decimalUnitToKm[sourceUnit]); + return decimalDivide(valueInKm, decimalUnitToKm[targetUnit], DECIMAL_DIVISION_PRECISION); + }; + + const formatValue = function (value) { + if (!value) { + return ''; + } + + return formatDecimal(value, 10); }; const cosmologyE = function (z) { return Math.sqrt(OMEGA_M * Math.pow(1 + z, 3) + OMEGA_LAMBDA); }; - const integrateSimpson = function (fn, start, end, steps) { - if (steps % 2 !== 0) { - steps += 1; + const adaptiveSimpson = function (fn, start, end, epsilon, maxDepth) { + const simpson = function (left, right, leftValue, middleValue, rightValue) { + return (right - left) * (leftValue + (4 * middleValue) + rightValue) / 6; + }; + + const recurse = function (left, right, eps, whole, leftValue, middleValue, rightValue, depth) { + const middle = (left + right) / 2; + const leftMiddle = (left + middle) / 2; + const rightMiddle = (middle + right) / 2; + const leftMiddleValue = fn(leftMiddle); + const rightMiddleValue = fn(rightMiddle); + const leftArea = simpson(left, middle, leftValue, leftMiddleValue, middleValue); + const rightArea = simpson(middle, right, middleValue, rightMiddleValue, rightValue); + const delta = leftArea + rightArea - whole; + + if (depth <= 0 || Math.abs(delta) <= 15 * eps) { + return leftArea + rightArea + (delta / 15); + } + + return recurse(left, middle, eps / 2, leftArea, leftValue, leftMiddleValue, middleValue, depth - 1) + + recurse(middle, right, eps / 2, rightArea, middleValue, rightMiddleValue, rightValue, depth - 1); + }; + + const leftValue = fn(start); + const rightValue = fn(end); + const middle = (start + end) / 2; + const middleValue = fn(middle); + const whole = simpson(start, end, leftValue, middleValue, rightValue); + + if (!Number.isFinite(whole)) { + return Number.NaN; } - const h = (end - start) / steps; - let sum = fn(start) + fn(end); - - for (let i = 1; i < steps; i += 1) { - const x = start + i * h; - sum += fn(x) * (i % 2 === 0 ? 2 : 4); - } - - return (h / 3) * sum; + return recurse(start, end, epsilon, whole, leftValue, middleValue, rightValue, maxDepth); }; + decimalConstants.zero = decimalFromInteger(0n); + decimalConstants.one = decimalFromInteger(1n); + decimalConstants.hundred = decimalFromInteger(100n); + decimalConstants.thousand = decimalFromInteger(1000n); + decimalConstants.million = decimalFromInteger(1000000n); + decimalConstants.speedOfLightKmS = parseDecimalConstant('299792.458'); + decimalConstants.secondsPerGyr = parseDecimalConstant('31557600000000000'); + decimalConstants.mpcInKm = parseDecimalConstant('30856775814913673000'); + decimalConstants.kmPerAu = parseDecimalConstant('149597870.7'); + decimalConstants.kmPerLightMinute = parseDecimalConstant('17987547.48'); + decimalConstants.kmPerLightHour = parseDecimalConstant('1079252848.8'); + decimalConstants.kmPerLy = parseDecimalConstant('9460730472580.8'); + decimalConstants.kmPerPc = parseDecimalConstant('30856775814913.67'); + decimalConstants.kmPerMpc = parseDecimalConstant('30856775814913670000'); + decimalConstants.jdUnixEpoch = parseDecimalConstant('2440587.5'); + decimalConstants.mjdOffset = parseDecimalConstant('2400000.5'); + decimalConstants.msPerDay = decimalFromInteger(86400000n); + decimalConstants.msPerSecond = decimalFromInteger(1000n); + const updateTooltip = function (field) { const wrapper = field.closest('.astro-conv-field'); const tooltipId = field.getAttribute('data-tooltip-id'); const tooltip = tooltipId ? document.getElementById(tooltipId) : null; - const numericValue = parseGermanNumber(field.value); + const rawValue = String(field.value || '').trim(); - if (!wrapper || !tooltip || !Number.isFinite(numericValue)) { + if (!wrapper || !tooltip || rawValue === '') { if (wrapper) { wrapper.classList.remove('is-tooltip-open'); } @@ -438,7 +703,7 @@ function h(?string $value): string return; } - tooltip.textContent = window.numberToGermanText(numericValue); + tooltip.textContent = window.numberToGermanText(rawValue); }; const attachTooltip = function (field, key) { @@ -504,13 +769,12 @@ function h(?string $value): string return; } - const numericValue = parseGermanNumber(rawValue); - if (!Number.isFinite(numericValue)) { + const decimalValue = parseDecimalInput(rawValue); + if (!decimalValue) { return; } - const valueInKm = numericValue * unitToKm[sourceUnit]; - distanceFields[sourceUnit].value = formatValue(numericValue); + distanceFields[sourceUnit].value = formatValue(decimalValue); updateTooltip(distanceFields[sourceUnit]); Object.entries(distanceFields).forEach(function ([unit, field]) { @@ -518,7 +782,7 @@ function h(?string $value): string return; } - field.value = formatValue(valueInKm / unitToKm[unit]); + field.value = formatValue(convertDistanceDecimal(decimalValue, sourceUnit, unit)); updateTooltip(field); }); }; @@ -547,47 +811,55 @@ function h(?string $value): string }); const updateRedshift = function () { - const z = parseGermanNumber(redshiftFields.z.value.trim()); - const restNm = parseGermanNumber(redshiftFields.restNm.value.trim()); + const zDecimal = parseDecimalInput(redshiftFields.z.value.trim()); + const restNmDecimal = parseDecimalInput(redshiftFields.restNm.value.trim()); + const z = zDecimal ? decimalToNumber(zDecimal) : Number.NaN; + const restNm = restNmDecimal ? decimalToNumber(restNmDecimal) : Number.NaN; - if (!Number.isFinite(z) || !Number.isFinite(restNm) || z <= -1) { + if (!zDecimal || !restNmDecimal || !Number.isFinite(z) || !Number.isFinite(restNm) || z <= -1) { return; } - const factor = 1 + z; - const scaleFactor = 1 / factor; - const observedNm = restNm * factor; - const frequencyFactor = 1 / factor; - const velocityApprox = z * SPEED_OF_LIGHT_KM_S; - const betaRel = ((factor * factor) - 1) / ((factor * factor) + 1); - const velocityRel = betaRel * SPEED_OF_LIGHT_KM_S; - const velocityC = betaRel * 100; - const integrationSteps = Math.max(200, Math.ceil(z * 200)); - const comovingIntegral = integrateSimpson(function (currentZ) { + const factorDecimal = decimalAdd(decimalConstants.one, zDecimal); + const factor = decimalToNumber(factorDecimal); + const scaleFactorDecimal = decimalDivide(decimalConstants.one, factorDecimal, DECIMAL_DIVISION_PRECISION); + const observedNmDecimal = decimalMultiply(restNmDecimal, factorDecimal); + const frequencyFactorDecimal = decimalDivide(decimalConstants.one, factorDecimal, DECIMAL_DIVISION_PRECISION); + const velocityApproxDecimal = decimalMultiply(zDecimal, decimalConstants.speedOfLightKmS); + const factorSquaredDecimal = decimalSquare(factorDecimal); + const betaRelDecimal = decimalDivide( + decimalSubtract(factorSquaredDecimal, decimalConstants.one), + decimalAdd(factorSquaredDecimal, decimalConstants.one), + DECIMAL_DIVISION_PRECISION + ); + const velocityRelDecimal = decimalMultiply(betaRelDecimal, decimalConstants.speedOfLightKmS); + const velocityCDecimal = decimalMultiply(betaRelDecimal, decimalConstants.hundred); + + const comovingIntegral = adaptiveSimpson(function (currentZ) { return 1 / cosmologyE(currentZ); - }, 0, z, integrationSteps); - const lookbackIntegral = integrateSimpson(function (currentZ) { + }, 0, z, 1e-10, 20); + const lookbackIntegral = adaptiveSimpson(function (currentZ) { return 1 / ((1 + currentZ) * cosmologyE(currentZ)); - }, 0, z, integrationSteps); + }, 0, z, 1e-10, 20); const hubbleTimeSeconds = MPC_IN_KM / H0; const comovingMpc = (SPEED_OF_LIGHT_KM_S / H0) * comovingIntegral; const luminosityMpc = comovingMpc * factor; const angularDiameterMpc = comovingMpc / factor; const lookbackGyr = (hubbleTimeSeconds * lookbackIntegral) / SECONDS_PER_GYR; - redshiftFields.z.value = formatValue(z); - redshiftFields.restNm.value = formatValue(restNm); - redshiftFields.factor.value = formatValue(factor); - redshiftFields.scaleFactor.value = formatValue(scaleFactor); - redshiftFields.observedNm.value = formatValue(observedNm); - redshiftFields.frequencyFactor.value = formatValue(frequencyFactor); - redshiftFields.velocityApprox.value = formatValue(velocityApprox); - redshiftFields.velocityRel.value = formatValue(velocityRel); - redshiftFields.velocityC.value = formatValue(velocityC); - redshiftFields.lookbackGyr.value = formatValue(lookbackGyr); - redshiftFields.comovingMpc.value = formatValue(comovingMpc); - redshiftFields.luminosityMpc.value = formatValue(luminosityMpc); - redshiftFields.angularDiameterMpc.value = formatValue(angularDiameterMpc); + redshiftFields.z.value = formatValue(zDecimal); + redshiftFields.restNm.value = formatValue(restNmDecimal); + redshiftFields.factor.value = formatValue(factorDecimal); + redshiftFields.scaleFactor.value = formatValue(scaleFactorDecimal); + redshiftFields.observedNm.value = formatValue(observedNmDecimal); + redshiftFields.frequencyFactor.value = formatValue(frequencyFactorDecimal); + redshiftFields.velocityApprox.value = formatValue(velocityApproxDecimal); + redshiftFields.velocityRel.value = formatValue(velocityRelDecimal); + redshiftFields.velocityC.value = formatValue(velocityCDecimal); + redshiftFields.lookbackGyr.value = formatValue(decimalFromNumber(lookbackGyr, 18)); + redshiftFields.comovingMpc.value = formatValue(decimalFromNumber(comovingMpc, 18)); + redshiftFields.luminosityMpc.value = formatValue(decimalFromNumber(luminosityMpc, 18)); + redshiftFields.angularDiameterMpc.value = formatValue(decimalFromNumber(angularDiameterMpc, 18)); Object.values(redshiftFields).forEach(function (field) { updateTooltip(field); @@ -654,12 +926,21 @@ function h(?string $value): string return parts.join('-') + ' ' + timeParts.join(':'); }; - const dateToJulianDate = function (date) { - return (date.getTime() / 86400000) + 2440587.5; + const dateToJulianDateDecimal = function (date) { + const unixMsDecimal = decimalFromInteger(BigInt(date.getTime())); + return decimalAdd( + decimalDivide(unixMsDecimal, decimalConstants.msPerDay, DECIMAL_DIVISION_PRECISION), + decimalConstants.jdUnixEpoch + ); }; - const julianDateToDate = function (jd) { - return new Date((jd - 2440587.5) * 86400000); + const julianDateDecimalToDate = function (jdDecimal) { + const unixMsDecimal = decimalMultiply( + decimalSubtract(jdDecimal, decimalConstants.jdUnixEpoch), + decimalConstants.msPerDay + ); + const unixMs = decimalRoundToBigInt(unixMsDecimal); + return new Date(Number(unixMs)); }; const updateTimeFrom = function (sourceUnit) { @@ -668,28 +949,31 @@ function h(?string $value): string if (sourceUnit === 'utc') { date = parseUtcDateTime(timeFields.utc.value); } else if (sourceUnit === 'jd') { - const jd = parseGermanNumber(timeFields.jd.value.trim()); - date = Number.isFinite(jd) ? julianDateToDate(jd) : null; + const jdDecimal = parseDecimalInput(timeFields.jd.value.trim()); + date = jdDecimal ? julianDateDecimalToDate(jdDecimal) : null; } else if (sourceUnit === 'mjd') { - const mjd = parseGermanNumber(timeFields.mjd.value.trim()); - date = Number.isFinite(mjd) ? julianDateToDate(mjd + 2400000.5) : null; + const mjdDecimal = parseDecimalInput(timeFields.mjd.value.trim()); + date = mjdDecimal ? julianDateDecimalToDate(decimalAdd(mjdDecimal, decimalConstants.mjdOffset)) : null; } else if (sourceUnit === 'unix') { - const unixSeconds = parseGermanNumber(timeFields.unix.value.trim()); - date = Number.isFinite(unixSeconds) ? new Date(unixSeconds * 1000) : null; + const unixSecondsDecimal = parseDecimalInput(timeFields.unix.value.trim()); + if (unixSecondsDecimal) { + const unixMs = decimalRoundToBigInt(decimalMultiply(unixSecondsDecimal, decimalConstants.msPerSecond)); + date = new Date(Number(unixMs)); + } } if (!(date instanceof Date) || Number.isNaN(date.getTime())) { return; } - const jd = dateToJulianDate(date); - const mjd = jd - 2400000.5; - const unixSeconds = Math.floor(date.getTime() / 1000); + const jdDecimal = dateToJulianDateDecimal(date); + const mjdDecimal = decimalSubtract(jdDecimal, decimalConstants.mjdOffset); + const unixSecondsDecimal = decimalDivide(decimalFromInteger(BigInt(date.getTime())), decimalConstants.msPerSecond, 10); timeFields.utc.value = formatUtcDateTime(date); - timeFields.jd.value = formatValue(jd); - timeFields.mjd.value = formatValue(mjd); - timeFields.unix.value = formatValue(unixSeconds); + timeFields.jd.value = formatValue(jdDecimal); + timeFields.mjd.value = formatValue(mjdDecimal); + timeFields.unix.value = formatValue(unixSecondsDecimal); Object.values(timeFields).forEach(function (field) { updateTooltip(field); diff --git a/public/js/number_to_german_words.js b/public/js/number_to_german_words.js index b7fcb17..968639a 100644 --- a/public/js/number_to_german_words.js +++ b/public/js/number_to_german_words.js @@ -3,19 +3,18 @@ const ONES_COMPOUND = ['', 'ein', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht', 'neun']; const TEENS = ['zehn', 'elf', 'zwoelf', 'dreizehn', 'vierzehn', 'fuenfzehn', 'sechzehn', 'siebzehn', 'achtzehn', 'neunzehn']; const TENS = ['', '', 'zwanzig', 'dreissig', 'vierzig', 'fuenfzig', 'sechzig', 'siebzig', 'achtzig', 'neunzig']; + const DIGIT_WORDS = ['null', 'eins', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht', 'neun']; const SCALES = [ - { singular: '', plural: '', join: true }, - { singular: 'Tausend', plural: 'Tausend', join: true }, - { singular: 'Million', plural: 'Millionen', join: false }, - { singular: 'Milliarde', plural: 'Milliarden', join: false }, - { singular: 'Billion', plural: 'Billionen', join: false }, - { singular: 'Billiarde', plural: 'Billiarden', join: false }, - { singular: 'Trillion', plural: 'Trillionen', join: false }, - { singular: 'Trilliarde', plural: 'Trilliarden', join: false }, - { singular: 'Quadrillion', plural: 'Quadrillionen', join: false } - + { singular: '', plural: '' }, + { singular: 'Tausend', plural: 'Tausend' }, + { singular: 'Million', plural: 'Millionen' }, + { singular: 'Milliarde', plural: 'Milliarden' }, + { singular: 'Billion', plural: 'Billionen' }, + { singular: 'Billiarde', plural: 'Billiarden' }, + { singular: 'Trillion', plural: 'Trillionen' }, + { singular: 'Trilliarde', plural: 'Trilliarden' }, + { singular: 'Quadrillion', plural: 'Quadrillionen' } ]; - const GROUP_NUMBER_FORMATTER = new Intl.NumberFormat('de-DE'); function chunkToWords(value) { if (value === 0) { @@ -52,67 +51,95 @@ return text; } - function integerToWords(value) { - if (!Number.isFinite(value)) { + function normalizeGermanNumberString(value) { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return ''; + } + + return String(value).replace('.', ','); + } + + if (typeof value !== 'string') { return ''; } - if (value === 0) { + return value.trim().replace(/\s+/g, ''); + } + + function splitNormalizedNumber(value) { + const normalized = normalizeGermanNumberString(value); + if (normalized === '') { + return null; + } + + const sign = normalized.startsWith('-') ? '-' : ''; + const unsigned = sign ? normalized.slice(1) : normalized; + const cleaned = unsigned.replace(/\./g, ''); + const parts = cleaned.split(','); + + if (parts.length > 2) { + return null; + } + + const integerPart = (parts[0] || '0').replace(/^0+(?=\d)/, '') || '0'; + const fractionalPart = parts[1] || ''; + + if (!/^\d+$/.test(integerPart) || (fractionalPart !== '' && !/^\d+$/.test(fractionalPart))) { + return null; + } + + return { sign, integerPart, fractionalPart }; + } + + function integerStringToWords(integerString, sign) { + if (integerString === '0') { return 'null'; } - let number = Math.floor(Math.abs(value)); const groups = []; - - while (number > 0) { - groups.push(number % 1000); - number = Math.floor(number / 1000); + for (let index = integerString.length; index > 0; index -= 3) { + groups.push(integerString.slice(Math.max(0, index - 3), index)); } const parts = []; - for (let i = groups.length - 1; i >= 0; i -= 1) { - const groupValue = groups[i]; + const groupValue = Number(groups[i]); if (groupValue === 0) { continue; } - const scale = SCALES[i] || { singular: '', plural: '', join: false }; + const scale = SCALES[i] || { singular: '', plural: '' }; if (i >= 2) { - const scaleLabel = groupValue === 1 ? scale.singular : scale.plural; - const prefix = groupValue === 1 ? 'eine' : GROUP_NUMBER_FORMATTER.format(groupValue); - parts.push(prefix + ' ' + scaleLabel); + const prefix = groupValue === 1 ? 'eine' : String(groupValue); + const label = groupValue === 1 ? scale.singular : scale.plural; + parts.push(prefix + ' ' + label); } else if (i === 1) { - parts.push(GROUP_NUMBER_FORMATTER.format(groupValue) + ' ' + scale.singular); + parts.push(groupValue === 1 ? 'ein ' + scale.singular : String(groupValue) + ' ' + scale.singular); } else { - parts.push(GROUP_NUMBER_FORMATTER.format(groupValue)); + parts.push(String(groupValue)); } } const result = parts.join('\n'); - return value < 0 ? 'minus ' + result : result; + return sign === '-' ? 'minus ' + result : result; } function numberToGermanText(value) { - if (!Number.isFinite(value)) { + const parsed = splitNormalizedNumber(value); + if (!parsed) { return ''; } - const integerPart = Math.trunc(value); - const fractionalPart = Math.abs(value - integerPart); - const integerText = integerToWords(integerPart); - - if (fractionalPart < 1e-10) { + const integerText = integerStringToWords(parsed.integerPart, parsed.sign); + if (!parsed.fractionalPart || /^0+$/.test(parsed.fractionalPart)) { return integerText; } - const decimalDigits = fractionalPart - .toFixed(10) - .replace(/^0\./, '') - .replace(/0+$/, '') + const decimalDigits = parsed.fractionalPart .split('') .map(function (digit) { - return ['null', 'eins', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht', 'neun'][Number(digit)]; + return DIGIT_WORDS[Number(digit)]; }) .join(' ');