diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 3907b66e..de3a99d2 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -55,6 +55,27 @@ describe('arithmetic', () => { it('complex expression', () => expectValue('2 + 3 * 4 - 1', '13')) it('implicit multiplication', () => expectValue('6 (3)', '18')) it('grouped thousands', () => expectValue('5 300', '5,300')) + it('grouped thousands with unit suffix', () => { + const result = evalLine('1km + 1 000m') + expect(result.type).toBe('unit') + expect(result.value).toContain('km') + }) +}) + +describe('rounding', () => { + it('to 2 dp', () => expectValue('1/3 to 2 dp', '0.33')) + it('to 5 digits', () => expectValue('pi to 5 digits', '3.14159')) + it('rounded', () => expectValue('5.5 rounded', '6')) + it('rounded down', () => expectValue('5.5 rounded down', '5')) + it('rounded up', () => expectValue('5.5 rounded up', '6')) + it('to nearest 10', () => expectValue('37 to nearest 10', '40')) + it('rounded to nearest thousand', () => + expectValue('2100 to nearest thousand', '2,000')) + it('rounded up to nearest 5', () => + expectValue('21 rounded up to nearest 5', '25')) + it('rounded down to nearest 3', () => + expectValue('17 rounded down to nearest 3', '15')) + it('to nearest hundred', () => expectValue('490 to nearest hundred', '500')) }) describe('math aliases', () => { @@ -64,6 +85,33 @@ describe('math aliases', () => { it('arctan', () => expectNumericClose('arctan(1)', Math.PI / 4, 4)) it('root 2 (8)', () => expectNumericClose('root 2 (8)', Math.sqrt(8), 4)) it('log 2 (8)', () => expectNumericClose('log 2 (8)', 3, 4)) + it('exp(1)', () => expectNumericClose('exp(1)', Math.E, 4)) + it('log2(8)', () => expectNumericClose('log2(8)', 3, 4)) + it('log10(1000)', () => expectNumericClose('log10(1000)', 3, 4)) + + // Phrase syntax + it('square root of 81', () => expectValue('square root of 81', '9')) + it('cube root of 27', () => expectValue('cube root of 27', '3')) + it('root 5 of 100', () => expectNumericClose('root 5 of 100', 2.5119, 3)) + it('log 20 base 4', () => expectNumericClose('log 20 base 4', 2.161, 2)) + it('81 is 9 to what power', () => + expectNumericClose('81 is 9 to what power', 2, 2)) + it('27 is 3 to the what', () => + expectNumericClose('27 is 3 to the what', 3, 2)) + + // Degree trig functions + it('sind(90)', () => expectNumericClose('sind(90)', 1, 4)) + it('cosd(0)', () => expectNumericClose('cosd(0)', 1, 4)) + it('tand(45)', () => expectNumericClose('tand(45)', 1, 4)) + it('asind(0.5)', () => expectNumericClose('asind(0.5)', 30, 4)) + it('acosd(0.5)', () => expectNumericClose('acosd(0.5)', 60, 4)) + it('atand(1)', () => expectNumericClose('atand(1)', 45, 4)) + it('sin(90 degrees)', () => expectNumericClose('sin(90 degrees)', 1, 4)) + + // Hyperbolic inverse + it('asinh(1)', () => expectNumericClose('asinh(1)', 0.8814, 3)) + it('acosh(2)', () => expectNumericClose('acosh(2)', 1.317, 2)) + it('atanh(0.5)', () => expectNumericClose('atanh(0.5)', 0.5493, 3)) }) describe('word operators', () => { @@ -79,6 +127,10 @@ describe('word operators', () => { it('divide by', () => expectValue('100 divide by 4', '25')) it('mul', () => expectValue('5 mul 6', '30')) it('mod', () => expectValue('17 mod 5', '2')) + it('to the power of', () => expectValue('3 to the power of 2', '9')) + it('remainder of X divided by Y', () => + expectValue('remainder of 21 divided by 5', '1')) + it('divided by', () => expectValue('1000 divided by 200', '5')) it('does not replace words inside variable names', () => { const results = evalLines('width = 100\nwidth * 2') @@ -154,6 +206,69 @@ describe('percentage advanced', () => { it('Y% of what is X', () => expectNumericClose('5% of what is 6', 120)) it('Y% on what is X', () => expectNumericClose('5% on what is 6', 5.71)) it('Y% off what is X', () => expectNumericClose('5% off what is 6', 6.32)) + + // Percentage change + it('X to Y is what %', () => expectNumericClose('50 to 75 is what %', 50)) + it('X to Y as %', () => expectNumericClose('40 to 90 as %', 125)) + it('X is what % off Y', () => + expectNumericClose('180 is what % off 200', 10)) + it('X is what % on Y', () => expectNumericClose('180 is what % on 150', 20)) + it('X is what % of Y', () => expectNumericClose('20 is what % of 200', 10)) + it('X as a % of Y (alt)', () => expectNumericClose('20 as a % of 200', 10)) + + // Reverse lookup: X is Y% of/on/off what + it('X is Y% of what', () => expectNumericClose('20 is 10% of what', 200)) + it('X is Y% on what', () => expectNumericClose('220 is 10% on what', 200)) + it('X is Y% off what', () => expectNumericClose('180 is 10% off what', 200)) + + // Decimal/fraction to percentage + it('0.35 as %', () => expectNumericClose('0.35 as %', 35)) + it('X/Y as %', () => expectNumericClose('20/200 as %', 10)) + it('X/Y %', () => expectNumericClose('20/200 %', 10)) +}) + +describe('fractions', () => { + it('2/10 as fraction', () => { + const result = evalLine('2/10 as fraction') + expect(result.value).toBe('1/5') + }) + it('50% as fraction', () => { + const result = evalLine('50% as fraction') + expect(result.value).toBe('1/2') + }) + it('2/3 of 600', () => expectNumericClose('2/3 of 600', 400)) + it('50 is 1/5 of what', () => expectNumericClose('50 is 1/5 of what', 250)) +}) + +describe('multipliers', () => { + it('20/5 as multiplier', () => { + const result = evalLine('20/5 as multiplier') + expect(result.value).toBe('4x') + }) + it('50 as x of 5', () => { + const result = evalLine('50 as x of 5') + expect(result.value).toBe('10x') + }) + it('2 as multiplier of 1', () => { + const result = evalLine('2 as multiplier of 1') + expect(result.value).toBe('2x') + }) + it('2 as multiplier on 1', () => { + const result = evalLine('2 as multiplier on 1') + expect(result.value).toBe('1x') + }) + it('1 as x off 2', () => { + const result = evalLine('1 as x off 2') + expect(result.value).toBe('0.5x') + }) + it('50 to 75 is what x', () => { + const result = evalLine('50 to 75 is what x') + expect(result.value).toBe('1.5x') + }) + it('20 to 40 as x', () => { + const result = evalLine('20 to 40 as x') + expect(result.value).toBe('2x') + }) }) describe('scales', () => { @@ -369,6 +484,113 @@ describe('unit conversion', () => { expect(result.value).toContain('gram') expectNumericClose('1 carat to gram', 0.2, 2) }) + + // Speed + it('60 mph to m/s', () => { + const result = evalLine('60 mph to m/s') + expect(result.type).toBe('unit') + expectNumericClose('60 mph to m/s', 26.82, 1) + }) + it('100 km/h to mph', () => { + const result = evalLine('100 km/h to mph') + expect(result.type).toBe('unit') + expectNumericClose('100 km/h to mph', 62.14, 1) + }) + it('10 knots to km/h', () => { + expectNumericClose('10 knots to kmh', 18.52, 1) + }) + + // Energy + it('1000 cal to J', () => { + expectNumericClose('1000 calorie to J', 4184, 0) + }) + it('1 kcal to calorie', () => { + expectNumericClose('1 kcal to calorie', 1000, 0) + }) + + // Maritime & Astro + it('1 fathom to m', () => { + expectNumericClose('1 fathom to m', 1.8288, 3) + }) + it('1 light year to km', () => { + const result = evalLine('1 light year to km') + expect(result.type).toBe('unit') + }) + + // Mass + it('1000 mcg to gram', () => { + expectNumericClose('1000 mcg to gram', 0.001, 4) + }) + + // Volume + it('1 bushel to liter', () => { + expectNumericClose('1 bushel to liter', 35.24, 1) + }) + + // Frequency (native math.js) + it('1 kHz to Hz', () => { + expectNumericClose('1 kHz to Hz', 1000, 0) + }) + + // Power (native math.js) + it('1 hp to W', () => { + expectNumericClose('1 hp to W', 745.7, 0) + }) + + // Data IEC (native math.js) + it('1 GiB to MiB', () => { + expectNumericClose('1 GiB to MiB', 1024, 0) + }) + + // Angle + it('90 deg to rad', () => { + expectNumericClose('90 deg to rad', 1.5708, 3) + }) + + // Reverse conversion syntax + it('meters in 10 km', () => { + expectNumericClose('meters in 10 km', 10000, 0) + }) + it('days in 3 weeks', () => { + expectNumericClose('days in 3 weeks', 21, 0) + }) + + // Shorthand conversion + it('km m (shorthand)', () => { + expectNumericClose('km m', 1000, 0) + }) + + // Larger unit wins (native math.js) + it('1km + 1000m = 2 km', () => { + const result = evalLine('1 km + 1000 m') + expect(result.value).toContain('km') + }) + + // Area from multiplication (native math.js) + it('10m * 10m = area', () => { + const result = evalLine('10 m * 10 m') + expect(result.type).toBe('unit') + }) +}) + +describe('rates', () => { + it('$50/week * 12 weeks', () => { + const result = evalLine('$50/week * 12 weeks') + expect(result.type).toBe('unit') + }) + it('30 hours at $30/hour', () => { + const result = evalLine('30 hours at $30/hour') + expect(result.type).toBe('unit') + }) + it('implicit rate: $30 * 4 days = $120', () => { + const result = evalLine('$30 * 4 days') + expect(result.value).toBe('120 USD') + expect(result.type).toBe('unit') + }) + it('$99 per week * 4 weeks', () => { + const result = evalLine('$99 per week * 4 weeks') + expect(result.type).toBe('unit') + }) }) describe('css units', () => { @@ -456,11 +678,28 @@ describe('number format', () => { it('hex input', () => expectValue('0xFF', '255')) it('binary input', () => expectValue('0b1010', '10')) it('octal input', () => expectValue('0o377', '255')) + it('scientific notation input 1e5', () => expectValue('1e5', '100,000')) + it('scientific notation input 2.5e3', () => expectValue('2.5e3', '2,500')) it('0xFF in hex roundtrip', () => { const result = evalLine('0xFF in hex') expect(result.value).toBe('0xFF') }) + + it('$100 as number', () => { + const result = evalLine('$100 as number') + expect(result.numericValue).toBe(100) + }) + + it('20% as dec', () => { + const result = evalLine('20% as dec') + expect(result.value).toBe('0.2') + }) + + it('50% to decimal', () => { + const result = evalLine('50% to decimal') + expect(result.value).toBe('0.5') + }) }) describe('numericValue for total', () => { @@ -782,6 +1021,46 @@ describe('average and avg', () => { }) }) +describe('median', () => { + it('median of odd count', () => { + const results = evalLines('10\n20\n30\nmedian') + expect(results[3].value).toBe('20') + expect(results[3].type).toBe('aggregate') + }) + + it('median of even count', () => { + const results = evalLines('10\n20\n30\n40\nmedian') + expect(results[4].value).toBe('25') + }) + + it('median resets after empty line', () => { + const results = evalLines('10\n20\n\n100\nmedian') + expect(results[4].value).toBe('100') + }) +}) + +describe('count', () => { + it('count of lines above', () => { + const results = evalLines('10\n20\n30\ncount') + expect(results[3].value).toBe('3') + expect(results[3].type).toBe('aggregate') + }) + + it('count resets after empty line', () => { + const results = evalLines('10\n20\n\n5\ncount') + expect(results[4].value).toBe('1') + }) +}) + +describe('inline aggregates', () => { + it('total of list', () => expectValue('total of 3, 4, 7 and 9', '23')) + it('average of list', () => + expectNumericClose('average of 36, 42, 19 and 81', 44.5)) + it('count of list', () => expectValue('count of 1, 2, 3, 4, 5', '5')) + it('median of list', () => expectValue('median of 10, 20 and 30', '20')) + it('sum of list', () => expectValue('sum of 10, 20, 30', '60')) +}) + describe('comments', () => { it('// comment', () => { const result = evalLine('// This is a comment') @@ -970,6 +1249,36 @@ describe('time zones', () => { describe('constants', () => { it('pi', () => expectNumericClose('pi', 3.14159, 4)) it('e', () => expectNumericClose('e', 2.71828, 4)) + it('tau', () => expectNumericClose('tau', 6.28318, 4)) + it('phi', () => expectNumericClose('phi', 1.61803, 4)) +}) + +describe('conditional logic', () => { + it('if-then-else (true branch)', () => + expectValue('if 5 > 3 then 10 else 20', '10')) + it('if-then-else (false branch)', () => + expectValue('if 5 < 3 then 10 else 20', '20')) + it('comparison ==', () => expectValue('5 == 5', 'true')) + it('comparison !=', () => expectValue('5 != 3', 'true')) + it('comparison >', () => expectValue('5 > 3', 'true')) + it('comparison <', () => expectValue('5 < 3', 'false')) + it('comparison >=', () => expectValue('5 >= 5', 'true')) + it('comparison <=', () => expectValue('3 <= 5', 'true')) + it('boolean true', () => expectValue('true', 'true')) + it('boolean false', () => expectValue('false', 'false')) + it('and operator', () => expectValue('true and false', 'false')) + it('or operator', () => expectValue('true or false', 'true')) + it('&& operator', () => expectValue('true && false', 'false')) + it('|| operator', () => expectValue('true || false', 'true')) + it('compound condition', () => expectValue('5 > 3 and 10 > 7', 'true')) + it('boolean assignment', () => { + const results = evalLines('x = true\nx and false') + expect(results[1].value).toBe('false') + }) + it('postfix if', () => expectValue('42 if 5 > 3', '42')) + it('postfix if (false)', () => expectValue('42 if 5 < 3', '0')) + it('postfix unless', () => expectValue('42 unless 5 > 3', '0')) + it('postfix unless (false)', () => expectValue('42 unless 5 < 3', '42')) }) describe('bitwise operations', () => { @@ -980,6 +1289,87 @@ describe('bitwise operations', () => { it('right shift', () => expectValue('16 >> 2', '4')) }) +describe('calendar calculations', () => { + it('days since a date', () => { + const result = evalLine('days since January 1') + expect(result.value).toContain('day') + expect(result.numericValue).toBeGreaterThan(0) + }) + + it('days till a date', () => { + const result = evalLine('days till December 25') + expect(result.value).toContain('day') + expect(result.numericValue).toBeGreaterThan(0) + }) + + it('days between two dates', () => { + const result = evalLine('days between March 1 and March 31') + expect(result.value).toBe('30 days') + expect(result.numericValue).toBe(30) + }) + + it('X from now', () => { + const result = evalLine('5 days from now') + expect(result.type).toBe('date') + }) + + it('X ago', () => { + const result = evalLine('3 days ago') + expect(result.type).toBe('date') + }) + + it('day of the week on date', () => { + const result = evalLine('day of the week on January 24, 1984') + expect(result.value).toBe('Tuesday') + }) + + it('weekday on date', () => { + const result = evalLine('weekday on March 9, 2024') + expect(result.value).toBe('Saturday') + }) + + it('week of year', () => { + const result = evalLine('week of year') + expect(result.numericValue).toBeGreaterThan(0) + expect(result.numericValue).toBeLessThanOrEqual(53) + }) + + it('week number on date', () => { + const result = evalLine('week number on March 12, 2021') + expect(result.numericValue).toBeGreaterThan(0) + }) + + it('days in February 2020 (leap)', () => { + const result = evalLine('days in February 2020') + expect(result.value).toBe('29 days') + }) + + it('days in February 2021 (non-leap)', () => { + const result = evalLine('days in February 2021') + expect(result.value).toBe('28 days') + }) + + it('days in Q3', () => { + const result = evalLine('days in Q3') + expect(result.value).toBe('92 days') + }) + + it('3 weeks after March 14, 2019', () => { + const result = evalLine('3 weeks after March 14, 2019') + expect(result.type).toBe('date') + }) + + it('28 days before March 12', () => { + const result = evalLine('28 days before March 12') + expect(result.type).toBe('date') + }) + + it('current timestamp', () => { + const result = evalLine('current timestamp') + expect(result.numericValue).toBeGreaterThan(1000000000) + }) +}) + describe('error handling', () => { it('invalid expression returns error', () => { const result = evalLine('hello world') diff --git a/src/renderer/composables/math-notebook/math-engine/calendar.ts b/src/renderer/composables/math-notebook/math-engine/calendar.ts new file mode 100644 index 00000000..b7beac0a --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/calendar.ts @@ -0,0 +1,285 @@ +import type { LineResult } from './types' +import { MONTH_NAME_TO_INDEX } from './constants' +import { formatMathDate } from './format' + +interface CalendarResult { + lineResult: LineResult + rawResult: any +} + +function parseSimpleDate(text: string, now: Date): Date | null { + const trimmed = text.trim().replace(/,/g, '') + + if (/^today$/i.test(trimmed)) + return new Date(now.getFullYear(), now.getMonth(), now.getDate()) + if (/^tomorrow$/i.test(trimmed)) + return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) + if (/^yesterday$/i.test(trimmed)) + return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + + const iso = trimmed.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/) + if (iso) + return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) + + const mdy = trimmed.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/i) + if (mdy) { + const month = MONTH_NAME_TO_INDEX[mdy[1].toLowerCase()] + if (month) { + const year = mdy[3] ? Number(mdy[3]) : now.getFullYear() + return new Date(year, month - 1, Number(mdy[2])) + } + } + + const dmy = trimmed.match(/^(\d{1,2})\s+([a-z]+)(?:\s+(\d{4}))?$/i) + if (dmy) { + const month = MONTH_NAME_TO_INDEX[dmy[2].toLowerCase()] + if (month) { + const year = dmy[3] ? Number(dmy[3]) : now.getFullYear() + return new Date(year, month - 1, Number(dmy[1])) + } + } + + return null +} + +const DAY_NAMES_REVERSE = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] + +function daysBetween(a: Date, b: Date): number { + const msPerDay = 86400000 + return Math.round((b.getTime() - a.getTime()) / msPerDay) +} + +function daysResult(count: number): CalendarResult { + const abs = Math.abs(count) + return { + lineResult: { + value: `${abs} ${abs === 1 ? 'day' : 'days'}`, + error: null, + type: 'number', + numericValue: abs, + }, + rawResult: abs, + } +} + +function parseTimeUnit(text: string): string { + return text.toLowerCase().replace(/s$/, '') +} + +function applyDateOffset( + date: Date, + amount: number, + unit: string, + direction: number, +) { + if (unit === 'day') + date.setDate(date.getDate() + amount * direction) + else if (unit === 'week') + date.setDate(date.getDate() + amount * 7 * direction) + else if (unit === 'month') + date.setMonth(date.getMonth() + amount * direction) + else if (unit === 'year') + date.setFullYear(date.getFullYear() + amount * direction) +} + +function getWeekNumber(date: Date): number { + const startOfYear = new Date(date.getFullYear(), 0, 1) + const diff = date.getTime() - startOfYear.getTime() + return Math.ceil((diff / 86400000 + startOfYear.getDay() + 1) / 7) +} + +export function evaluateCalendarLine( + line: string, + now: Date, + locale: string, +): CalendarResult | null { + const lower = line.toLowerCase().trim() + + // "days since X" / "days till X" / "days until X" + const sinceTillIdx = lower.search(/^days\s+(since|till|until)\s/) + if (sinceTillIdx === 0) { + const keyword = lower.match(/^days\s+(since|till|until)/)![1] + const rest = line.slice(lower.indexOf(keyword) + keyword.length).trim() + const date = parseSimpleDate(rest, now) + if (date) { + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const diff + = keyword === 'since' + ? daysBetween(date, today) + : daysBetween(today, date) + return daysResult(diff) + } + } + + // "days between X and Y" + const betweenMatch = lower.match(/^days\s+between\s/) + if (betweenMatch) { + const rest = line.slice(betweenMatch[0].length) + const andIdx = rest.toLowerCase().lastIndexOf(' and ') + if (andIdx > 0) { + const d1 = parseSimpleDate(rest.slice(0, andIdx), now) + const d2 = parseSimpleDate(rest.slice(andIdx + 5), now) + if (d1 && d2) { + return daysResult(daysBetween(d1, d2)) + } + } + } + + // "X from now" / "X ago" + const relMatch = lower.match( + /^(\d+)\s+(days?|weeks?|months?|years?)\s+(from now|ago)$/, + ) + if (relMatch) { + const amount = Number(relMatch[1]) + const unit = parseTimeUnit(relMatch[2]) + const direction = relMatch[3] === 'ago' ? -1 : 1 + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + applyDateOffset(today, amount, unit, direction) + return { + lineResult: { + value: formatMathDate(today, locale), + error: null, + type: 'date', + }, + rawResult: today, + } + } + + // "day of the week on DATE" / "weekday on DATE" + const dowMatch = lower.match(/^(?:day of the week|weekday) on\s/) + if (dowMatch) { + const rest = line.slice(dowMatch[0].length) + const date = parseSimpleDate(rest, now) + if (date) { + const dayName = DAY_NAMES_REVERSE[date.getDay()] + return { + lineResult: { value: dayName, error: null, type: 'number' }, + rawResult: dayName, + } + } + } + + // "week of year" / "week number" + if (/^week of year$/.test(lower) || /^week number$/.test(lower)) { + const weekNumber = getWeekNumber( + new Date(now.getFullYear(), now.getMonth(), now.getDate()), + ) + return { + lineResult: { + value: String(weekNumber), + error: null, + type: 'number', + numericValue: weekNumber, + }, + rawResult: weekNumber, + } + } + + // "week number on DATE" + const weekOnMatch = lower.match(/^week number on\s/) + if (weekOnMatch) { + const rest = line.slice(weekOnMatch[0].length) + const date = parseSimpleDate(rest, now) + if (date) { + const weekNumber = getWeekNumber(date) + return { + lineResult: { + value: String(weekNumber), + error: null, + type: 'number', + numericValue: weekNumber, + }, + rawResult: weekNumber, + } + } + } + + // "days in MONTH YEAR" / "days in February 2020" + const daysInMonthMatch = lower.match(/^days in ([a-z]+)(?:\s+(\d{4}))?$/) + if (daysInMonthMatch) { + const month = MONTH_NAME_TO_INDEX[daysInMonthMatch[1]] + if (month) { + const year = daysInMonthMatch[2] + ? Number(daysInMonthMatch[2]) + : now.getFullYear() + const daysInMonth = new Date(year, month, 0).getDate() + return { + lineResult: { + value: `${daysInMonth} days`, + error: null, + type: 'number', + numericValue: daysInMonth, + }, + rawResult: daysInMonth, + } + } + } + + // "days in Q1/Q2/Q3/Q4" + const daysInQMatch = lower.match(/^days in q([1-4])(?:\s+(\d{4}))?$/) + if (daysInQMatch) { + const quarter = Number(daysInQMatch[1]) + const year = daysInQMatch[2] ? Number(daysInQMatch[2]) : now.getFullYear() + const startMonth = (quarter - 1) * 3 + let total = 0 + for (let i = 0; i < 3; i++) { + total += new Date(year, startMonth + i + 1, 0).getDate() + } + return { + lineResult: { + value: `${total} days`, + error: null, + type: 'number', + numericValue: total, + }, + rawResult: total, + } + } + + // "X after/before DATE" + const offsetMatch = lower.match( + /^(\d+)\s+(days?|weeks?|months?|years?)\s+(after|before)\s/, + ) + if (offsetMatch) { + const amount = Number(offsetMatch[1]) + const unit = parseTimeUnit(offsetMatch[2]) + const direction = offsetMatch[3] === 'after' ? 1 : -1 + const rest = line.slice(offsetMatch[0].length) + const date = parseSimpleDate(rest, now) + if (date) { + applyDateOffset(date, amount, unit, direction) + return { + lineResult: { + value: formatMathDate(date, locale), + error: null, + type: 'date', + }, + rawResult: date, + } + } + } + + // "current timestamp" + if (lower === 'current timestamp') { + const ts = Math.floor(now.getTime() / 1000) + return { + lineResult: { + value: String(ts), + error: null, + type: 'number', + numericValue: ts, + }, + rawResult: ts, + } + } + + return null +} diff --git a/src/renderer/composables/math-notebook/math-engine/constants.ts b/src/renderer/composables/math-notebook/math-engine/constants.ts index 20dc8e80..27e73823 100644 --- a/src/renderer/composables/math-notebook/math-engine/constants.ts +++ b/src/renderer/composables/math-notebook/math-engine/constants.ts @@ -182,6 +182,40 @@ export const knownUnitTokens = new Set([ 'point', 'points', 'em', + 'mph', + 'kmh', + 'knot', + 'knots', + 'kn', + 'hz', + 'khz', + 'mhz', + 'ghz', + 'thz', + 'joule', + 'joules', + 'calorie', + 'calories', + 'cal', + 'kcal', + 'watt', + 'watts', + 'kilowatt', + 'kilowatts', + 'horsepower', + 'hp', + 'fathom', + 'fathoms', + 'ftm', + 'lightyear', + 'lightyears', + 'ly', + 'microgram', + 'micrograms', + 'mcg', + 'bushel', + 'bushels', + 'bsh', ...Object.keys(currencyWordNames), ]) @@ -241,6 +275,15 @@ export const MATH_UNARY_FUNCTIONS = [ 'sinh', 'cosh', 'tanh', + 'asinh', + 'acosh', + 'atanh', + 'sind', + 'cosd', + 'tand', + 'asind', + 'acosd', + 'atand', ] as const export const TIME_UNIT_TOKEN_MAP: Record = { @@ -281,6 +324,15 @@ export const HUMANIZED_UNIT_NAMES: Record< league: { singular: 'league', plural: 'leagues' }, are: { singular: 'are', plural: 'ares' }, carat: { singular: 'carat', plural: 'carats' }, + mph: { singular: 'mph', plural: 'mph' }, + kmh: { singular: 'km/h', plural: 'km/h' }, + knot: { singular: 'knot', plural: 'knots' }, + calorie: { singular: 'calorie', plural: 'calories' }, + kilocalorie: { singular: 'kilocalorie', plural: 'kilocalories' }, + fathom: { singular: 'fathom', plural: 'fathoms' }, + lightyear: { singular: 'light year', plural: 'light years' }, + microgram: { singular: 'microgram', plural: 'micrograms' }, + bushel: { singular: 'bushel', plural: 'bushels' }, } export const MONTH_NAME_TO_INDEX: Record = { diff --git a/src/renderer/composables/math-notebook/math-engine/mathInstance.ts b/src/renderer/composables/math-notebook/math-engine/mathInstance.ts index a0fb16cf..5f51dfc9 100644 --- a/src/renderer/composables/math-notebook/math-engine/mathInstance.ts +++ b/src/renderer/composables/math-notebook/math-engine/mathInstance.ts @@ -29,6 +29,12 @@ export function createMathInstance(currencyRates: Record) { arcsin: (x: number) => mathInstance.asin(x), arccos: (x: number) => mathInstance.acos(x), arctan: (x: number) => mathInstance.atan(x), + sind: (x: number) => Math.sin((x * Math.PI) / 180), + cosd: (x: number) => Math.cos((x * Math.PI) / 180), + tand: (x: number) => Math.tan((x * Math.PI) / 180), + asind: (x: number) => (Math.asin(x) * 180) / Math.PI, + acosd: (x: number) => (Math.acos(x) * 180) / Math.PI, + atand: (x: number) => (Math.atan(x) * 180) / Math.PI, unitValue: (value: any) => value && typeof value.toNumber === 'function' ? value.toNumber() @@ -111,6 +117,52 @@ export function createMathInstance(currencyRates: Record) { aliases: ['leagues'], }) + // Speed + createUnitSafe(mathInstance, 'mph', { + definition: '1 mile/hour', + aliases: [], + }) + createUnitSafe(mathInstance, 'kmh', { + definition: '1 km/hour', + aliases: [], + }) + createUnitSafe(mathInstance, 'knot', { + definition: '0.514444 m/s', + aliases: ['knots', 'kn'], + }) + + // Energy + createUnitSafe(mathInstance, 'calorie', { + definition: '4.184 J', + aliases: ['cal', 'calories'], + }) + createUnitSafe(mathInstance, 'kilocalorie', { + definition: '4184 J', + aliases: ['kcal', 'kCal', 'kilocalories'], + }) + + // Maritime & Astro + createUnitSafe(mathInstance, 'fathom', { + definition: '1.8288 m', + aliases: ['fathoms', 'ftm'], + }) + createUnitSafe(mathInstance, 'lightyear', { + definition: '9.461e15 m', + aliases: ['lightyears', 'ly'], + }) + + // Mass + createUnitSafe(mathInstance, 'microgram', { + definition: '1e-6 gram', + aliases: ['micrograms', 'mcg'], + }) + + // Volume + createUnitSafe(mathInstance, 'bushel', { + definition: '35.2391 liter', + aliases: ['bushels', 'bsh'], + }) + for (const [code, rate] of Object.entries(currencyRates)) { if (code === 'USD') continue diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index c302cd40..0cb511ce 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -44,7 +44,7 @@ function sanitizeForCurrencyDetection(line: string) { } function preprocessGroupedNumbers(line: string): string { - let result = line.replace(/\b\d{1,3}(?:\s\d{3})+\b/g, match => + let result = line.replace(/\b\d{1,3}(?:\s\d{3})+(?=\b|[a-z])/gi, match => match.replace(/\s+/g, '')) result = result.replace(/\b\d+(?:\s+\d+)+\b/g, match => @@ -100,11 +100,46 @@ function normalizePowerUnit(unit: string) { } } +function preprocessReverseConversion(line: string): string { + // "meters in 10 km" → "10 km to meters" + // "days in 3 weeks" → "3 weeks to days" + return line.replace( + /^([a-z]+)\s+in\s+(\d+(?:\.\d+)?)\s+([a-z]+)$/i, + (_, targetUnit: string, value: string, sourceUnit: string) => { + if ( + knownUnitTokens.has(targetUnit.toLowerCase()) + && knownUnitTokens.has(sourceUnit.toLowerCase()) + ) { + return `${value} ${sourceUnit} to ${targetUnit}` + } + return _ + }, + ) +} + +function preprocessShorthandConversion(line: string): string { + // "km m" → "1 km to m" (two unit names = show conversion factor) + return line.replace( + /^([a-z]+)\s+([a-z]+)$/i, + (_, unit1: string, unit2: string) => { + if ( + knownUnitTokens.has(unit1.toLowerCase()) + && knownUnitTokens.has(unit2.toLowerCase()) + ) { + return `1 ${unit1} to ${unit2}` + } + return _ + }, + ) +} + function preprocessUnitAliases(line: string): string { return line .replace(/\btea\s+spoons?\b/gi, 'teaspoon') .replace(/\btable\s+spoons?\b/gi, 'tablespoon') .replace(/\bnautical\s+miles?\b/gi, 'nauticalmile') + .replace(/\blight\s+years?\b/gi, 'lightyear') + .replace(/\bkm\/h\b/gi, 'kmh') } function preprocessAreaVolumeAliases(line: string): string { @@ -135,6 +170,74 @@ function preprocessAreaVolumeAliases(line: string): string { return line } +function preprocessRates(line: string): string { + return ( + line + // "X per Y" → "X / Y" (e.g. $99 per week → $99 / week) + .replace(/\bper\b/gi, '/') + // "X at Y/Z" where Y/Z is a rate → "X * Y / Z" + // "30 hours at $30/hour" → "30 hours * $30 / hour" + .replace( + /(\d+(?:\.\d+)?)\s+(\w+)\s+at\s+(\S+)\/(\w+)/gi, + '$1 $2 * $3 / $4', + ) + // "X a day for Y" → "X / day * Y" (e.g. $24 a day for a year) + .replace( + /(\S+)\s+a\s+(day|week|month|year)\s+for\s+(?:a\s+)?(\S+)/gi, + '$1 / $2 * 1 $3', + ) + ) +} + +function preprocessMultipliers(line: string): string { + return ( + line + // X to Y is what x → Y / X as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s+x\b/gi, + '$2 / $1 as multiplier', + ) + // X to Y as x → Y / X as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s+x\b/gi, + '$2 / $1 as multiplier', + ) + // X as x of Y → X / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+x\s+of\s+(\d+(?:\.\d+)?)/gi, + '$1 / $2 as multiplier', + ) + // X as multiplier of Y → X / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+of\s+(\d+(?:\.\d+)?)/gi, + '$1 / $2 as multiplier', + ) + // X as multiplier on Y → (X - Y) / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+on\s+(\d+(?:\.\d+)?)/gi, + '($1 - $2) / $2 as multiplier', + ) + // X as x off Y → (Y - X) / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+x\s+off\s+(\d+(?:\.\d+)?)/gi, + '($2 - $1) / $2 as multiplier', + ) + ) +} + +function preprocessPhraseFunctions(line: string): string { + return line + .replace(/\bsquare\s+root\s+of\s+(\S+)/gi, 'sqrt($1)') + .replace(/\bcube\s+root\s+of\s+(\S+)/gi, 'cbrt($1)') + .replace(/\broot\s+(\d+)\s+of\s+(\S+)/gi, 'root($1, $2)') + .replace(/\blog\s+(\S+)\s+base\s+(\S+)/gi, 'log($1, $2)') + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s+to\s+(?:what|the\s+what)\s*(?:power)?/gi, + 'log($1) / log($2)', + ) + .replace(/\b(sin|cos|tan)\((\d+(?:\.\d+)?)\s+degrees\)/gi, '$1($2 deg)') +} + function preprocessFunctionExpression(expression: string): string { const trimmed = expression.trim() const openIndex = trimmed.indexOf('(') @@ -415,61 +518,174 @@ function preprocessStackedUnits(line: string): string { } function preprocessImplicitMultiplication(line: string): string { - return line.replace(/(\d)\s*\(/g, '$1 * (') + return line.replace(/\b(\d+)\s*\(/g, '$1 * (') +} + +function preprocessConditionals(line: string): string { + const lower = line.toLowerCase() + + // if COND then EXPR else EXPR → (COND) ? (EXPR) : (EXPR) + const thenIdx = lower.indexOf(' then ') + const elseIdx = lower.indexOf(' else ') + if (/^if\s/i.test(line) && thenIdx > 2 && elseIdx > thenIdx) { + const cond = line.slice(3, thenIdx).trim() + const thenExpr = line.slice(thenIdx + 6, elseIdx).trim() + const elseExpr = line.slice(elseIdx + 6).trim() + return `(${cond}) ? (${thenExpr}) : (${elseExpr})` + } + + // EXPR unless COND → (COND) ? 0 : (EXPR) (check before postfix if) + const unlessIdx = lower.indexOf(' unless ') + if (unlessIdx > 0) { + const expr = line.slice(0, unlessIdx).trim() + const cond = line.slice(unlessIdx + 8).trim() + return `(${cond}) ? 0 : (${expr})` + } + + // EXPR if COND → (COND) ? (EXPR) : 0 + const ifIdx = lower.indexOf(' if ') + if (ifIdx > 0) { + const expr = line.slice(0, ifIdx).trim() + const cond = line.slice(ifIdx + 4).trim() + return `(${cond}) ? (${expr}) : 0` + } + + // && → and, || → or + return line.replace(/&&/g, ' and ').replace(/\|\|/g, ' or ') } function preprocessWordOperators(line: string): string { - return line + const hasConditional = /\?|[><=!]=?|\btrue\b|\bfalse\b/.test(line) + + let result = line + .replace(/\bremainder\s+of\s+(\S+)\s+divided\s+by\s+(\S+)/gi, '$1 % $2') + .replace(/\bto\s+the\s+power\s+of\b/gi, '^') .replace(/\bmultiplied\s+by\b/gi, '*') + .replace(/\bdivided\s+by\b/gi, '/') .replace(/\bdivide\s+by\b/gi, '/') .replace(/(\S+)\s+xor\s+(\S+)/gi, 'bitXor($1, $2)') .replace(/\btimes\b/gi, '*') .replace(/\bdivide\b/gi, '/') .replace(/\bplus\b/gi, '+') - .replace(/\band\b/gi, '+') .replace(/\bwith\b/gi, '+') .replace(/\bminus\b/gi, '-') .replace(/\bsubtract\b/gi, '-') .replace(/\bwithout\b/gi, '-') .replace(/\bmul\b/gi, '*') .replace(/\bmod\b/gi, '%') + + if (!hasConditional) { + result = result.replace(/\band\b/gi, '+') + } + + return result } function preprocessPercentages(line: string): string { - return line - .replace( - /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / ($1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+on\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / (1 + $1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+off\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / (1 - $1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+of\s+(\d+(?:\.\d+)?)/gi, - '($1 / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+on\s+(\d+(?:\.\d+)?)/gi, - '(($1 - $2) / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+off\s+(\d+(?:\.\d+)?)/gi, - '(($2 - $1) / $2) * 100', - ) - .replace(/(\d+(?:\.\d+)?)%\s+on\s+(\d+(?:\.\d+)?)/gi, '$2 * (1 + $1 / 100)') - .replace( - /(\d+(?:\.\d+)?)%\s+off\s+(\d+(?:\.\d+)?)/gi, - '$2 * (1 - $1 / 100)', - ) - .replace(/(\d+(?:\.\d+)?)%\s+of\s+(\d+(?:\.\d+)?)/gi, '$1 / 100 * $2') - .replace(/(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 + $2 / 100)') - .replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 - $2 / 100)') - .replace(/(\d+(?:\.\d+)?)%(?!\s*\w)/g, '$1 / 100') + return ( + line + // Z is X/Y of what → Z / (X/Y) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+what\b/gi, + '$1 / ($2 / $3)', + ) + // X/Y of Z → (X/Y) * Z (fraction of value) + .replace( + /(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\b/gi, + '($1 / $2) * $3', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / ($1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+on\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / (1 + $1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+off\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / (1 - $1 / 100)', + ) + // X is Y% of what → X / (Y / 100) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+of\s+what\b/gi, + '$1 / ($2 / 100)', + ) + // X is Y% on what → X / (1 + Y/100) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+on\s+what\b/gi, + '$1 / (1 + $2 / 100)', + ) + // X is Y% off what → X / (1 - Y/100) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+off\s+what\b/gi, + '$1 / (1 - $2 / 100)', + ) + // X to Y is what % → ((Y - X) / X) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s*%/gi, + '(($2 - $1) / $1) * 100', + ) + // X to Y as % → ((Y - X) / X) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s*%/gi, + '(($2 - $1) / $1) * 100', + ) + // X is what % off Y → ((Y - X) / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+off\s+(\d+(?:\.\d+)?)/gi, + '(($2 - $1) / $2) * 100', + ) + // X is what % on Y → ((X - Y) / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+on\s+(\d+(?:\.\d+)?)/gi, + '(($1 - $2) / $2) * 100', + ) + // X is what % of Y → (X / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+of\s+(\d+(?:\.\d+)?)/gi, + '($1 / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+of\s+(\d+(?:\.\d+)?)/gi, + '($1 / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+on\s+(\d+(?:\.\d+)?)/gi, + '(($1 - $2) / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+off\s+(\d+(?:\.\d+)?)/gi, + '(($2 - $1) / $2) * 100', + ) + // X/Y as % → (X / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+as\s*%/gi, + '($1 / $2) * 100', + ) + // X/Y % → (X / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*%(?!\s*\w)/g, + '($1 / $2) * 100', + ) + // 0.35 as % → 0.35 * 100 + .replace(/(\d+(?:\.\d+)?)\s+as\s*%/gi, '$1 * 100') + .replace( + /(\d+(?:\.\d+)?)%\s+on\s+(\d+(?:\.\d+)?)/gi, + '$2 * (1 + $1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+off\s+(\d+(?:\.\d+)?)/gi, + '$2 * (1 - $1 / 100)', + ) + .replace(/(\d+(?:\.\d+)?)%\s+of\s+(\d+(?:\.\d+)?)/gi, '$1 / 100 * $2') + .replace( + /(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)%/g, + '$1 * (1 + $2 / 100)', + ) + .replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 - $2 / 100)') + .replace(/(\d+(?:\.\d+)?)%(?!\s*\w)/g, '$1 / 100') + ) } function preprocessConversions(line: string): string { @@ -532,18 +748,24 @@ export function hasCurrencyExpression(line: string) { export function preprocessMathExpression(line: string) { let processed = preprocessLabels(line) processed = preprocessQuotedText(processed) + processed = preprocessReverseConversion(processed) + processed = preprocessShorthandConversion(processed) processed = preprocessGroupedNumbers(processed) processed = preprocessDegreeSigns(processed) - processed = preprocessTimeUnits(processed) processed = preprocessUnitAliases(processed) + processed = preprocessTimeUnits(processed) processed = preprocessCurrencySymbols(processed) processed = preprocessCurrencyWords(processed) processed = preprocessScales(processed) processed = preprocessAreaVolumeAliases(processed) processed = preprocessStackedUnits(processed) + processed = preprocessRates(processed) + processed = preprocessMultipliers(processed) + processed = preprocessPhraseFunctions(processed) processed = preprocessFunctionSyntax(processed) processed = preprocessFunctionConversions(processed) processed = preprocessImplicitMultiplication(processed) + processed = preprocessConditionals(processed) processed = preprocessWordOperators(processed) processed = inferAdditiveUnits(processed) processed = preprocessPercentages(processed) diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 9321913d..ce8e98ce 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -4,10 +4,12 @@ import type { LineResult, SpecialLineResult, } from './math-engine/types' +import { evaluateCalendarLine } from './math-engine/calendar' import { DEFAULT_EM_IN_PX, DEFAULT_PPI, HUMANIZED_UNIT_NAMES, + SUPPORTED_CURRENCY_CODES, } from './math-engine/constants' import { evaluateCssLine } from './math-engine/css' import { formatMathDate, formatMathNumber } from './math-engine/format' @@ -25,10 +27,126 @@ import { export type { LineResult } from './math-engine/types' interface FormatDirective { - format: 'hex' | 'bin' | 'oct' | 'sci' | null + format: + | 'hex' + | 'bin' + | 'oct' + | 'sci' + | 'number' + | 'dec' + | 'fraction' + | 'multiplier' + | null expression: string } +interface RoundingDirective { + type: + | 'dp' + | 'round' + | 'ceil' + | 'floor' + | 'nearest' + | 'nearestCeil' + | 'nearestFloor' + | null + param: number + expression: string +} + +const NEAREST_WORDS: Record = { + ten: 10, + hundred: 100, + thousand: 1000, + million: 1000000, +} + +function detectRoundingDirective(line: string): RoundingDirective { + const lower = line.toLowerCase() + let m: RegExpMatchArray | null + + // "to N dp" / "to N digits" + m = lower.match(/\s+to\s+(\d+)\s+(?:dp|digits?)$/) + if (m) { + return { + type: 'dp', + param: Number(m[1]), + expression: line.slice(0, m.index!).trim(), + } + } + + // "rounded up to nearest X" / "rounded down to nearest X" + m = lower.match(/\s+rounded\s+(up|down)\s+to\s+nearest\s+(\w+)$/) + if (m) { + const n = NEAREST_WORDS[m[2]] || Number(m[2]) + if (n > 0) { + return { + type: m[1] === 'up' ? 'nearestCeil' : 'nearestFloor', + param: n, + expression: line.slice(0, m.index!).trim(), + } + } + } + + // "rounded to nearest X" / "to nearest X" + m = lower.match(/\s+(?:rounded\s+)?to\s+nearest\s+(\w+)$/) + if (m) { + const n = NEAREST_WORDS[m[1]] || Number(m[1]) + if (n > 0) { + return { + type: 'nearest', + param: n, + expression: line.slice(0, m.index!).trim(), + } + } + } + + // "rounded up" / "rounded down" + m = lower.match(/\s+rounded\s+(up|down)$/) + if (m) { + return { + type: m[1] === 'up' ? 'ceil' : 'floor', + param: 0, + expression: line.slice(0, m.index!).trim(), + } + } + + // "rounded" + m = lower.match(/\s+rounded$/) + if (m) { + return { + type: 'round', + param: 0, + expression: line.slice(0, m.index!).trim(), + } + } + + return { type: null, param: 0, expression: line } +} + +function applyRounding(value: number, directive: RoundingDirective): number { + switch (directive.type) { + case 'dp': { + const factor = 10 ** directive.param + return Math.round(value * factor) / factor + } + case 'round': + return Math.round(value) + case 'ceil': + return Math.ceil(value) + case 'floor': + return Math.floor(value) + case 'nearest': + return Math.round(value / directive.param) * directive.param + case 'nearestCeil': + return Math.ceil(value / directive.param) * directive.param + case 'nearestFloor': + return Math.floor(value / directive.param) * directive.param + default: + return value + } +} + let activeCurrencyRates: Record = {} let currencyServiceState: CurrencyServiceState = 'loading' let currencyUnavailableMessage = '' @@ -37,13 +155,69 @@ let math = createMathInstance(activeCurrencyRates) let activeLocale = 'en-US' let activeDecimalPlaces = 6 +const STRIP_UNIT_SUFFIXES: Record< + string, + 'number' | 'dec' | 'fraction' | 'multiplier' +> = { + 'as number': 'number', + 'to number': 'number', + 'as decimal': 'dec', + 'to decimal': 'dec', + 'as dec': 'dec', + 'to dec': 'dec', + 'as fraction': 'fraction', + 'to fraction': 'fraction', + 'as multiplier': 'multiplier', + 'to multiplier': 'multiplier', +} + +function gcd(a: number, b: number): number { + a = Math.abs(Math.round(a)) + b = Math.abs(Math.round(b)) + while (b) { + const t = b + b = a % b + a = t + } + return a +} + +function toFraction(decimal: number): string { + if (Number.isInteger(decimal)) + return `${decimal}/1` + const sign = decimal < 0 ? '-' : '' + const abs = Math.abs(decimal) + const precision = 1e10 + const numerator = Math.round(abs * precision) + const denominator = precision + const divisor = gcd(numerator, denominator) + return `${sign}${numerator / divisor}/${denominator / divisor}` +} + +function detectStripUnitDirective(line: string): FormatDirective { + const lower = line.toLowerCase() + for (const [suffix, format] of Object.entries(STRIP_UNIT_SUFFIXES)) { + if (lower.endsWith(suffix)) { + return { + format, + expression: line.slice(0, line.length - suffix.length).trim(), + } + } + } + return { format: null, expression: line } +} + function detectFormatDirective(line: string): FormatDirective { - const formatMap: Record = { + const formatMap: Record< + string, + 'hex' | 'bin' | 'oct' | 'sci' | 'multiplier' + > = { 'in hex': 'hex', 'in bin': 'bin', 'in oct': 'oct', 'in sci': 'sci', 'in scientific': 'sci', + 'to multiplier': 'multiplier', } const lower = line.toLowerCase() @@ -63,6 +237,105 @@ function applyFormat( result: any, format: NonNullable, ): LineResult { + if (format === 'multiplier') { + let num: number + if (typeof result === 'number') { + num = result + } + else if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + num = result.toNumber() + } + catch { + num = Number.NaN + } + } + else { + num = Number(result) + } + + if (Number.isNaN(num)) { + return { value: String(result), error: null, type: 'number' } + } + + return { + value: `${formatMathNumber(num, activeLocale, activeDecimalPlaces)}x`, + error: null, + type: 'number', + numericValue: num, + } + } + + if (format === 'fraction') { + let num: number + if (typeof result === 'number') { + num = result + } + else if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + num = result.toNumber() + } + catch { + num = Number.NaN + } + } + else { + num = Number(result) + } + + if (Number.isNaN(num)) { + return { value: String(result), error: null, type: 'number' } + } + + return { + value: toFraction(num), + error: null, + type: 'number', + numericValue: num, + } + } + + if (format === 'number' || format === 'dec') { + let num: number + if (typeof result === 'number') { + num = result + } + else if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + num = result.toNumber() + } + catch { + num = Number.NaN + } + } + else { + num = Number(result) + } + + if (Number.isNaN(num)) { + return { value: String(result), error: null, type: 'number' } + } + + return { + value: formatMathNumber(num, activeLocale, activeDecimalPlaces), + error: null, + type: 'number', + numericValue: num, + } + } + const num = typeof result === 'number' ? result @@ -110,8 +383,13 @@ function applyFormat( } } +function humanizeUnitToken(unitId: string) { + const displayUnit = HUMANIZED_UNIT_NAMES[unitId] + return displayUnit ? displayUnit.plural : unitId +} + function humanizeFormattedUnits(value: string) { - return value.replace( + let result = value.replace( /(-?\d[\d,]*(?:\.\d+)?)\s+([a-z][a-z0-9]*)\b/gi, (match, amountText: string, unitId: string) => { const numericAmount = Number.parseFloat(amountText.replace(/,/g, '')) @@ -135,6 +413,16 @@ function humanizeFormattedUnits(value: string) { return `${formattedAmount} ${unitLabel}` }, ) + + // Humanize remaining standalone unit tokens (e.g. in compound units like "USD mcday") + result = result.replace( + /\b(mc(?:second|minute|hour|day|week|month|year))\b/g, + (_, unitId: string) => { + return humanizeUnitToken(unitId) + }, + ) + + return result } function formatResult(result: any): LineResult { @@ -142,6 +430,10 @@ function formatResult(result: any): LineResult { return { value: null, error: null, type: 'empty' } } + if (typeof result === 'boolean') { + return { value: String(result), error: null, type: 'number' } + } + if (result instanceof Date) { return { value: formatMathDate(result, activeLocale), @@ -156,6 +448,47 @@ function formatResult(result: any): LineResult { && typeof result.toNumber === 'function' && result.units ) { + // Detect compound currency*time units (implicit rate result) and simplify + if (Array.isArray(result.units) && result.units.length === 2) { + const units = result.units as Array<{ + unit: { name: string, value: number, base?: { key?: string } } + prefix: { value: number } + power: number + }> + const currencyUnit = units.find( + u => + u.unit.base?.key?.includes('STUFF') + || SUPPORTED_CURRENCY_CODES.includes(u.unit.name), + ) + const timeUnit = units.find(u => u.unit.base?.key === 'TIME') + if ( + currencyUnit + && timeUnit + && currencyUnit.power === 1 + && timeUnit.power === 1 + ) { + try { + const rawValue = result.value as number + const currencyScale + = currencyUnit.unit.value * currencyUnit.prefix.value + const timeScale = timeUnit.unit.value * timeUnit.prefix.value + const currencyAmount + = (rawValue / (currencyScale * timeScale)) * currencyScale + const simplified = math.unit(currencyAmount, currencyUnit.unit.name) + return { + value: humanizeFormattedUnits( + math.format(simplified, { precision: activeDecimalPlaces }), + ), + error: null, + type: 'unit', + } + } + catch { + // Fall through to default formatting + } + } + } + return { value: humanizeFormattedUnits( math.format(result, { precision: activeDecimalPlaces }), @@ -495,7 +828,112 @@ export function useMathEngine() { continue } + if (lowerTrimmed === 'median') { + if (numericBlock.length > 0) { + const sorted = [...numericBlock].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + const median + = sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2 + const formatted = formatResult(median) + formatted.type = 'aggregate' + results.push(formatted) + prevResult = median + numericBlock.push(median) + } + else { + results.push(formatResult(0)) + } + continue + } + + if (lowerTrimmed === 'count') { + const count = numericBlock.length + const formatted = formatResult(count) + formatted.type = 'aggregate' + results.push(formatted) + prevResult = count + numericBlock.push(count) + continue + } + + const inlineAggregateMatch = lowerTrimmed.match( + /^(total|sum|average|avg|median|count)\s+of\s+/i, + ) + if (inlineAggregateMatch) { + const fn = inlineAggregateMatch[1].toLowerCase() + const listStr = trimmed.slice(inlineAggregateMatch[0].length) + const items = listStr + .replace(/\band\b/gi, ',') + .split(',') + .map(s => s.trim()) + .filter(Boolean) + .map(Number) + .filter(n => !Number.isNaN(n)) + + if (items.length > 0) { + let value: number + if (fn === 'total' || fn === 'sum') { + value = items.reduce((a, b) => a + b, 0) + } + else if (fn === 'average' || fn === 'avg') { + value = items.reduce((a, b) => a + b, 0) / items.length + } + else if (fn === 'median') { + const sorted = [...items].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + value + = sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2 + } + else { + value = items.length + } + const formatted = formatResult(value) + formatted.type = 'aggregate' + results.push(formatted) + prevResult = value + numericBlock.push(value) + continue + } + } + try { + const roundingDirective = detectRoundingDirective(trimmed) + if (roundingDirective.type) { + const roundProcessed = preprocessMathExpression( + roundingDirective.expression, + ) + const roundRaw = math.evaluate(roundProcessed, scope) + const num = getNumericValue(roundRaw) + if (num !== null) { + const rounded = applyRounding(num, roundingDirective) + const formatted = formatResult(rounded) + results.push(formatted) + prevResult = rounded + numericBlock.push(rounded) + continue + } + } + + const stripDirective = detectStripUnitDirective(trimmed) + if (stripDirective.format) { + const stripProcessed = preprocessMathExpression( + stripDirective.expression, + ) + const stripResult = math.evaluate(stripProcessed, scope) + const formatted = applyFormat(stripResult, stripDirective.format) + results.push(formatted) + prevResult = stripResult + const numericValue = getNumericValue(stripResult) + if (numericValue !== null) { + numericBlock.push(numericValue) + } + continue + } + if ( currencyServiceState !== 'ready' && hasCurrencyExpression(trimmed) @@ -541,6 +979,21 @@ export function useMathEngine() { continue } + const calendarResult = evaluateCalendarLine( + trimmed, + currentDate, + activeLocale, + ) + if (calendarResult) { + results.push(calendarResult.lineResult) + prevResult = calendarResult.rawResult + const numericValue = getNumericValue(calendarResult.rawResult) + if (numericValue !== null) { + numericBlock.push(numericValue) + } + continue + } + const cssResult = evaluateCssLine(trimmed, cssContext) if (cssResult) { scope.em = cssContext.emPx @@ -587,8 +1040,9 @@ export function useMathEngine() { continue } - const { format, expression } = detectFormatDirective(processed) - const toEvaluate = format ? expression : processed + const { format, expression: formatExpression } + = detectFormatDirective(processed) + const toEvaluate = format ? formatExpression : processed const result = math.evaluate(toEvaluate, scope) if (result === undefined) {