Finds the coordinate systems a project's coordinates fit, reads and writes latitude and longitude text, applies a local grid fit, and converts a project's coordinates to another system.
How the precision is kept
64-bit arithmetic with one expression per result. Projections themselves are done by the transformer in crs.js, which this file calls. Latitude and longitude shown in the status bar are rounded for display to a hundredth of a second or six decimal degrees; typed values are parsed without rounding.
Calculations under this policy
7 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
The catalog systems in which a project point falls inside the system's area of use.
Each candidate converts the point to longitude and latitude and back; it is kept when the round trip returns within 0.001 project units. That limit is absolute, so it is looser for a meter project than for a feet project.
crs-pick.js
lines 27 to 45
CivilCraft 0.8.1
/** Catalog systems the project's coordinates fit (see the header). Sorted: state plane / UTM of current datums first, then
* the smallest area of use (the most specific system). */
export function systemsThatFit(pt, units, { limit = 60 } = {}) {
if (!pt || !Number.isFinite(pt.x) || !Number.isFinite(pt.y)) return { list: [], total: 0 };
const fam = familyOf(units);
const all = search('', { limit: 1e6 }).filter((e) => !e.geographic && fam.includes(e.unit));
const out = [];
for (const e of all) {
try {
const t = transformer(e, null), [lon, lat] = t(pt.x, pt.y);
if (!Number.isFinite(lon) || !Number.isFinite(lat) || !inArea(e.area, lon, lat)) continue;
const back = transformer(null, e)(lon, lat);
if (!(Math.hypot(back[0] - pt.x, back[1] - pt.y) < 1e-3)) continue;
out.push({ entry: e, lon, lat });
} catch { /* a definition proj4js cannot run here is just not a fit */ }
}
out.sort((a, b) => rankOf(a.entry) - rankOf(b.entry) || areaSize(a.entry.area) - areaSize(b.entry.area) || a.entry.code - b.entry.code);
return { list: out.slice(0, limit), total: out.length };
}
Latitude and longitude as degrees, minutes, seconds
64-bit
The status bar text for a latitude or longitude.
seconds = round(|v| × 360000) ÷ 100, then split into D, M, S
The value is rounded once to hundredths of a second before it is split, so the seconds never show 60.00. This is display rounding; the comment puts a hundredth of a second at about 0.3 m.
crs-pick.js
lines 106 to 111
CivilCraft 0.8.1
function dmsOf(v, pos, neg) {
let s = Math.round(Math.abs(v) * 360000) / 100; // hundredths of a second (about 0.3 m)
const d = Math.floor(s / 3600); s -= d * 3600;
const m = Math.floor(s / 60); s -= m * 60;
return d + '°' + String(m).padStart(2, '0') + '′' + s.toFixed(2).padStart(5, '0') + '″ ' + (v >= 0 ? pos : neg);
}
Latitude and longitude as decimal degrees
64-bit
The status bar text in decimal degrees.
Shown with 6 decimals by toFixed. This is display rounding of the text only.
crs-pick.js
lines 99 to 105
CivilCraft 0.8.1
/** Latitude and longitude for the status bar. */
export function latLonText(lon, lat, mode = 'dms') {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '';
// i18n-ignore: hemisphere letters N S E W (symbols; parseLatLon reads them back)
if (mode === 'dd') return Math.abs(lat).toFixed(6) + '° ' + (lat >= 0 ? 'N' : 'S') + ' ' + Math.abs(lon).toFixed(6) + '° ' + (lon >= 0 ? 'E' : 'W');
return dmsOf(lat, 'N', 'S') + ' ' + dmsOf(lon, 'E', 'W');
}
Typed latitude or longitude
64-bit
A typed angle, decimal or in degrees, minutes and seconds, with a sign or a hemisphere letter.
value = ±(D + M ÷ 60 + S ÷ 3600)
The parts are converted with Number and combined in one expression; nothing is rounded. Minutes or seconds of 60 or more are refused, and degrees must be whole when minutes are given.
crs-pick.js
lines 133 to 143
CivilCraft 0.8.1
function oneAngle(t) {
let s = t.trim(), sign = 1, axis = null;
const h = s.match(/^([NSEWnsew])\s*|\s*([NSEWnsew])$/);
if (h) { const c = (h[1] || h[2]).toUpperCase(); axis = c === 'N' || c === 'S' ? 'lat' : 'lon'; if (c === 'S' || c === 'W') sign = -1; s = s.replace(/^[NSEWnsew]\s*|\s*[NSEWnsew]$/, ''); }
if (s.startsWith('-')) { sign *= -1; s = s.slice(1); } else if (s.startsWith('+')) s = s.slice(1);
const n = s.trim().split(/\s+/);
if (!n.length || n.length > 3 || !n.every((x) => /^\d+(\.\d+)?$/.test(x))) return null;
const [d, m = 0, sec = 0] = n.map(Number);
if (n.length > 1 && (!Number.isInteger(d) || m >= 60 || sec >= 60)) return null;
return { v: sign * (d + m / 60 + sec / 3600), axis };
}
Local fit: project to grid
64-bit
Grid coordinates of a project point through a scale, rotation and shift.
grid = to + scale · R(rotation) · (p − from)
The difference from the fit's origin is taken first, so the rotation and scale act on a short vector and only the final addition involves full-size coordinates. The rotation is typed in degrees and multiplied by π ÷ 180.
crs-pick.js
lines 158 to 162
CivilCraft 0.8.1
/** The `local` step alone (crs.js frame().toGrid without a system): grid = to + scale · R(rotation) · (p − from). */
export function localToGrid(l, x, y) {
const c = Math.cos(l.rotation * Math.PI / 180), s = Math.sin(l.rotation * Math.PI / 180), dx = x - l.from[0], dy = y - l.from[1];
return [l.to[0] + l.scale * (c * dx - s * dy), l.to[1] + l.scale * (s * dx + c * dy)];
}
Misclosure of a third control point
64-bit
How far a two-point fit puts a third known point from its grid coordinates, as a distance and in parts per million.
ppm = distance ÷ span × 10⁶
The misclosure is a difference of two full-size grid coordinates, so it is known only to the rounding of those coordinates. The ratio is not computed when the span is zero.
crs-pick.js
lines 150 to 156
CivilCraft 0.8.1
/** A third control point against a fit: where the fit puts its project coordinates vs its known grid coordinates. */
export function misclosure(local, pLocal, pGrid) {
const g = localToGrid(local, pLocal[0], pLocal[1]);
const dx = pGrid[0] - g[0], dy = pGrid[1] - g[1], dist = Math.hypot(dx, dy);
const span = Math.hypot(pLocal[0] - local.from[0], pLocal[1] - local.from[1]);
return { dx, dy, dist, ppm: span > 0 ? dist / span * 1e6 : null };
}
Converting a project's coordinates
64-bit
Every stored x and y of the project (points, breaklines, boundary, alignment PIs, pads, structures) passed through a conversion.
Each coordinate pair is replaced by the result of the conversion function with no rounding. Elevations are not changed. Curve radii and other lengths stored with the objects are not rescaled here.
crs-pick.js
lines 171 to 184
CivilCraft 0.8.1
/** Converts every coordinate of a project (state + survey points) with map(x, y) → [x, y]. Call inside one undo step
* (Project.editPoints). Refuses a surface read from LandXML (its TIN is kept outside the undo snapshots). */
export function reprojectProject(state, points, map) {
if (state.surface?.tin) return { points, moved: 0, refused: t('crs.reproject.tin') };
let moved = 0;
const xy = (q) => { const [x, y] = map(q.x, q.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(t('crs.reproject.coord')); q.x = x; q.y = y; moved++; };
const pts = points.map((p) => { const [x, y] = map(p.x, p.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(t('crs.reproject.point', { n: p.n })); moved++; return { ...p, x, y }; });
for (const b of state.surface?.breaklines || []) b.pts.forEach(xy);
if (state.surface?.boundary) state.surface.boundary.forEach(xy);
for (const a of state.alignments || []) a.pis.forEach(xy);
for (const d of state.pads || []) d.pts.forEach(xy);
for (const s of state.network?.structures || []) xy(s);
return { points: pts, moved, refused: null };
}
In detail
Method
Local fit: grid = to + scale · R(rotation) · (p − from). Degrees, minutes and seconds: D + M ÷ 60 + S ÷ 3600.
Number types
64-bit numbers.
Tolerances
A system fits when the point converts to latitude and longitude inside its area of use and back to within 0.001 project units (absolute).
Limits
The accuracy of a conversion between systems is that of the datum shift, which crs.js reports separately.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.