diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..3576de43d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/README.md b/README.md index a1f92f5862..bf570f557c 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -See you later \ No newline at end of file +# LiuNian & LiuYue Generators + +Utilities for producing LiuNian (annual) and LiuYue (monthly) Heavenly Stem/Earthly Branch pillars within user-defined ranges. Each generated entry keeps track of the DaYun (great luck) cycle it belongs to, making it easy to analyse transitions across multiple cycles. + +## Installation + +```bash +npm install +``` + +## Usage + +```javascript +const { generateLiuNian, generateLiuYue } = require('./src/pillars'); + +const daYunCycles = [ + { + id: 'dy1', + name: 'First Luck', + startYear: 2000, + length: 10, + startPillar: '甲子', + startMonthPillar: '甲子', + }, + { + id: 'dy2', + name: 'Second Luck', + startYear: 2010, + length: 10, + startPillar: '甲戌', + startMonthPillar: '甲子', + }, +]; + +const annualPillars = generateLiuNian(daYunCycles, { fromYear: 2005, toYear: 2014 }); +const monthlyPillars = generateLiuYue(daYunCycles, { from: { year: 2009, month: 10 }, to: { year: 2010, month: 3 } }); + +console.log(annualPillars.map((entry) => `${entry.year} ${entry.pillar.label}`)); +console.log(monthlyPillars.map((entry) => `${entry.year}-${entry.month} ${entry.pillar.label}`)); +``` + +## Sample scenario + +A runnable scenario is provided in `examples/sample-scenario.js`: + +```bash +node examples/sample-scenario.js +``` + +The script prints the LiuNian pillars from 2005 to 2014 alongside a focused LiuYue slice that spans late 2009 to early 2010, illustrating how the DaYun association flows across cycles. + +## Tests + +```bash +npm test +``` diff --git a/examples/sample-scenario.js b/examples/sample-scenario.js new file mode 100644 index 0000000000..ab3291b8f4 --- /dev/null +++ b/examples/sample-scenario.js @@ -0,0 +1,39 @@ +'use strict'; + +const { generateLiuNian, generateLiuYue } = require('../src/pillars'); + +const daYunCycles = [ + { + id: 'dy1', + name: 'First Luck', + startYear: 2000, + length: 10, + startPillar: '甲子', + startMonthPillar: '甲子', + }, + { + id: 'dy2', + name: 'Second Luck', + startYear: 2010, + length: 10, + startPillar: '甲戌', + startMonthPillar: '甲子', + }, +]; + +const annualRange = { fromYear: 2005, toYear: 2014 }; +const monthlyRange = { from: { year: 2009, month: 10 }, to: { year: 2010, month: 3 } }; + +const annualPillars = generateLiuNian(daYunCycles, annualRange); +const monthlyPillars = generateLiuYue(daYunCycles, monthlyRange); + +console.log('Annual pillars (2005-2014):'); +annualPillars.forEach((entry) => { + console.log(`${entry.year} ${entry.pillar.label} — ${entry.daYun.name}`); +}); + +console.log('\nMonthly pillars spanning late 2009 to early 2010:'); +monthlyPillars.forEach((entry) => { + const monthLabel = String(entry.month).padStart(2, '0'); + console.log(`${entry.year}-${monthLabel} ${entry.pillar.label} — ${entry.daYun.name}`); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000000..b619416e42 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "liunian-liuyue-generators", + "version": "1.0.0", + "description": "Utilities to generate LiuNian (annual) and LiuYue (monthly) pillars linked to DaYun cycles.", + "main": "src/pillars.js", + "scripts": { + "test": "node --test" + }, + "keywords": [ + "liunian", + "liuyue", + "dayun", + "bazi", + "pillars" + ], + "author": "", + "license": "MIT" +} diff --git a/src/pillars.js b/src/pillars.js new file mode 100644 index 0000000000..e6f34b7e66 --- /dev/null +++ b/src/pillars.js @@ -0,0 +1,296 @@ +'use strict'; + +const HEAVENLY_STEMS = Object.freeze(['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']); +const EARTHLY_BRANCHES = Object.freeze(['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']); + +const PILLAR_CYCLE = buildPillarCycle(); +const PILLAR_BY_LABEL = new Map(PILLAR_CYCLE.map((pillar) => [pillar.label, pillar])); +const PILLAR_INDEX_BY_LABEL = new Map(PILLAR_CYCLE.map((pillar, index) => [pillar.label, index])); + +function buildPillarCycle() { + const cycle = []; + const stemsLength = HEAVENLY_STEMS.length; + const branchesLength = EARTHLY_BRANCHES.length; + + for (let i = 0; i < 60; i += 1) { + const stem = HEAVENLY_STEMS[i % stemsLength]; + const branch = EARTHLY_BRANCHES[i % branchesLength]; + const label = `${stem}${branch}`; + cycle.push({ stem, branch, label }); + } + + return cycle; +} + +function toPillarLabel(pillar) { + if (pillar === undefined || pillar === null) { + throw new Error('Pillar input must be provided.'); + } + + if (typeof pillar === 'string') { + const trimmed = pillar.trim(); + if (!PILLAR_BY_LABEL.has(trimmed)) { + throw new Error(`Unknown pillar label: ${pillar}`); + } + return trimmed; + } + + if (typeof pillar === 'object') { + if (pillar.label && PILLAR_BY_LABEL.has(pillar.label.trim())) { + return pillar.label.trim(); + } + + if (pillar.stem && pillar.branch) { + const composed = `${pillar.stem}${pillar.branch}`; + if (!PILLAR_BY_LABEL.has(composed)) { + throw new Error(`Unknown pillar combination: ${pillar.stem}${pillar.branch}`); + } + return composed; + } + } + + throw new Error('Invalid pillar input. Expected a label or an object with stem and branch.'); +} + +function clonePillarByLabel(label) { + const record = PILLAR_BY_LABEL.get(label); + if (!record) { + throw new Error(`Unknown pillar label: ${label}`); + } + return { stem: record.stem, branch: record.branch, label: record.label }; +} + +function pillarIndex(label) { + const index = PILLAR_INDEX_BY_LABEL.get(label); + if (index === undefined) { + throw new Error(`Unknown pillar label: ${label}`); + } + return index; +} + +function shiftPillar(pillar, offset) { + const label = toPillarLabel(pillar); + const baseIndex = pillarIndex(label); + const length = PILLAR_CYCLE.length; + const shiftedIndex = ((baseIndex + offset) % length + length) % length; + const target = PILLAR_CYCLE[shiftedIndex]; + return { stem: target.stem, branch: target.branch, label: target.label }; +} + +function ensureInteger(value, name) { + if (!Number.isInteger(value)) { + throw new Error(`${name} must be an integer.`); + } +} + +function ensurePositiveInteger(value, name) { + ensureInteger(value, name); + if (value <= 0) { + throw new Error(`${name} must be greater than zero.`); + } +} + +function resolveCycleYears(cycle) { + if (Number.isInteger(cycle.length) && cycle.length > 0) { + return cycle.length; + } + + if (Number.isInteger(cycle.years) && cycle.years > 0) { + return cycle.years; + } + + if (Number.isInteger(cycle.endYear)) { + const span = cycle.endYear - cycle.startYear + 1; + if (span <= 0) { + throw new Error('DaYun cycle endYear must be after startYear.'); + } + return span; + } + + throw new Error('DaYun cycle must specify a positive length, years, or endYear.'); +} + +function ensureChronologicalRange(from, to, unit) { + if (from !== undefined && to !== undefined && from > to) { + throw new Error(`Invalid ${unit} range: "from" cannot be greater than "to".`); + } +} + +function compareYearMonth(a, b) { + if (a.year !== b.year) { + return a.year - b.year; + } + return a.month - b.month; +} + +function validateYearMonthPoint(point, name) { + if (!point || typeof point !== 'object') { + throw new Error(`${name} must be an object with year and month.`); + } + ensureInteger(point.year, `${name}.year`); + ensureInteger(point.month, `${name}.month`); + if (point.month < 1 || point.month > 12) { + throw new Error(`${name}.month must be between 1 and 12.`); + } +} + +function isBeforeYearMonth(a, b) { + return compareYearMonth(a, b) < 0; +} + +function isAfterYearMonth(a, b) { + return compareYearMonth(a, b) > 0; +} + +function buildDaYunMeta(cycle, index, years) { + const daYunId = cycle.id || cycle.identifier || `dayun-${index + 1}`; + const name = cycle.name || cycle.label || `DaYun ${index + 1}`; + const cycleIndex = cycle.index || index + 1; + + return { + id: daYunId, + name, + index: cycleIndex, + startYear: cycle.startYear, + endYear: cycle.startYear + years - 1, + }; +} + +function generateLiuNian(cycles, options = {}) { + if (!Array.isArray(cycles) || cycles.length === 0) { + throw new Error('At least one DaYun cycle is required to generate LiuNian pillars.'); + } + + const sortedCycles = [...cycles].sort((a, b) => a.startYear - b.startYear); + const { fromYear, toYear } = options; + + if (fromYear !== undefined) { + ensureInteger(fromYear, 'fromYear'); + } + if (toYear !== undefined) { + ensureInteger(toYear, 'toYear'); + } + + ensureChronologicalRange(fromYear, toYear, 'year'); + + const results = []; + + sortedCycles.forEach((cycle, index) => { + if (cycle.startYear === undefined) { + throw new Error('DaYun cycle startYear is required.'); + } + ensureInteger(cycle.startYear, 'DaYun cycle startYear'); + + if (!cycle.startPillar) { + throw new Error('DaYun cycle startPillar is required to generate LiuNian pillars.'); + } + + const years = resolveCycleYears(cycle); + ensurePositiveInteger(years, 'DaYun cycle length'); + + const startPillarLabel = toPillarLabel(cycle.startPillar); + const daYunBaseMeta = buildDaYunMeta(cycle, index, years); + + for (let offset = 0; offset < years; offset += 1) { + const year = cycle.startYear + offset; + + if (fromYear !== undefined && year < fromYear) { + continue; + } + if (toYear !== undefined && year > toYear) { + break; + } + + const pillar = shiftPillar(startPillarLabel, offset); + results.push({ + year, + pillar, + daYun: { ...daYunBaseMeta }, + }); + } + }); + + results.sort((a, b) => a.year - b.year); + return results; +} + +function generateLiuYue(cycles, options = {}) { + if (!Array.isArray(cycles) || cycles.length === 0) { + throw new Error('At least one DaYun cycle is required to generate LiuYue pillars.'); + } + + const sortedCycles = [...cycles].sort((a, b) => a.startYear - b.startYear); + const { from, to } = options; + + if (from !== undefined) { + validateYearMonthPoint(from, 'from'); + } + if (to !== undefined) { + validateYearMonthPoint(to, 'to'); + } + + if (from && to && compareYearMonth(from, to) > 0) { + throw new Error('Invalid month range: "from" cannot be greater than "to".'); + } + + const results = []; + + sortedCycles.forEach((cycle, index) => { + if (cycle.startYear === undefined) { + throw new Error('DaYun cycle startYear is required.'); + } + ensureInteger(cycle.startYear, 'DaYun cycle startYear'); + + const years = resolveCycleYears(cycle); + ensurePositiveInteger(years, 'DaYun cycle length'); + + const monthSeed = cycle.startMonthPillar || cycle.startMonthlyPillar; + if (!monthSeed) { + throw new Error('DaYun cycle startMonthPillar is required to generate LiuYue pillars.'); + } + + const startMonthLabel = toPillarLabel(monthSeed); + const daYunBaseMeta = buildDaYunMeta(cycle, index, years); + + const totalMonths = years * 12; + + for (let offset = 0; offset < totalMonths; offset += 1) { + const absoluteYearOffset = Math.floor(offset / 12); + const year = cycle.startYear + absoluteYearOffset; + const month = (offset % 12) + 1; + const point = { year, month }; + + if (from && isBeforeYearMonth(point, from)) { + continue; + } + if (to && isAfterYearMonth(point, to)) { + break; + } + + const pillar = shiftPillar(startMonthLabel, offset); + results.push({ + year, + month, + pillar, + daYun: { ...daYunBaseMeta }, + }); + } + }); + + results.sort((a, b) => { + if (a.year !== b.year) { + return a.year - b.year; + } + return a.month - b.month; + }); + + return results; +} + +module.exports = { + HEAVENLY_STEMS, + EARTHLY_BRANCHES, + generateLiuNian, + generateLiuYue, + shiftPillar, +}; diff --git a/test/pillars.test.js b/test/pillars.test.js new file mode 100644 index 0000000000..e0e9d687b5 --- /dev/null +++ b/test/pillars.test.js @@ -0,0 +1,109 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { generateLiuNian, generateLiuYue } = require('../src/pillars'); + +const sampleDaYunCycles = [ + { + id: 'dy1', + name: 'First Luck', + index: 1, + startYear: 2000, + length: 10, + startPillar: '甲子', + startMonthPillar: '甲子', + }, + { + id: 'dy2', + name: 'Second Luck', + index: 2, + startYear: 2010, + length: 10, + startPillar: '甲戌', + startMonthPillar: '甲子', + }, +]; + +test('generateLiuNian produces chronological annual pillars within range', () => { + const result = generateLiuNian(sampleDaYunCycles, { fromYear: 2003, toYear: 2012 }); + + assert.equal(result[0].year, 2003); + assert.equal(result[result.length - 1].year, 2012); + assert.equal(result.length, 10); + + for (let i = 1; i < result.length; i += 1) { + assert.ok(result[i].year > result[i - 1].year, 'Years must be in ascending order'); + } + + const year2003 = result.find((entry) => entry.year === 2003); + assert.equal(year2003.pillar.label, '丁卯'); + assert.equal(year2003.daYun.id, 'dy1'); + + const year2012 = result.find((entry) => entry.year === 2012); + assert.equal(year2012.pillar.label, '丙子'); + assert.equal(year2012.daYun.id, 'dy2'); +}); + +test('generateLiuYue produces monthly pillars over a filtered range', () => { + const range = { + from: { year: 2009, month: 10 }, + to: { year: 2010, month: 3 }, + }; + const result = generateLiuYue(sampleDaYunCycles, range); + + assert.equal(result.length, 6); + assert.deepEqual( + result.map((entry) => [entry.year, entry.month]), + [ + [2009, 10], + [2009, 11], + [2009, 12], + [2010, 1], + [2010, 2], + [2010, 3], + ], + ); + + assert.equal(result[0].pillar.label, '辛酉'); + assert.equal(result[result.length - 1].pillar.label, '丙寅'); + + const months2009 = result.filter((entry) => entry.year === 2009); + months2009.forEach((entry) => assert.equal(entry.daYun.id, 'dy1')); + + const months2010 = result.filter((entry) => entry.year === 2010); + months2010.forEach((entry) => assert.equal(entry.daYun.id, 'dy2')); +}); + +test('generateLiuNian rejects an inverted year range', () => { + assert.throws( + () => generateLiuNian(sampleDaYunCycles, { fromYear: 2015, toYear: 2010 }), + /Invalid year range/, + ); +}); + +test('generateLiuYue rejects an inverted month range', () => { + assert.throws( + () => + generateLiuYue(sampleDaYunCycles, { + from: { year: 2011, month: 5 }, + to: { year: 2010, month: 12 }, + }), + /"from" cannot be greater than "to"/, + ); +}); + +test('generateLiuYue requires startMonthPillar information on each DaYun cycle', () => { + const incompleteCycles = [ + { + id: 'broken', + startYear: 2000, + length: 10, + startPillar: '甲子', + }, + ]; + + assert.throws( + () => generateLiuYue(incompleteCycles), + /startMonthPillar is required to generate LiuYue pillars/, + ); +});