Compare commits
109 Commits
3b4e201444
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63bafc2feb | |||
| d23e910aa7 | |||
| ae95a66668 | |||
| 0d099e34cd | |||
|
|
b44e9e5702 | ||
|
|
c20f2ebc60 | ||
|
|
56873816bb | ||
|
|
07a299275b | ||
|
|
17ed319fe7 | ||
|
|
de799c3a7b | ||
|
|
b1bf3f40d8 | ||
|
|
e4987d6764 | ||
|
|
6af3b23987 | ||
|
|
66d02c7f14 | ||
|
|
5651ecb6d4 | ||
|
|
3e26376584 | ||
| c006971cbe | |||
| de4fa5ba85 | |||
| 379bb60722 | |||
| 3ae77e02a0 | |||
| 1093208324 | |||
| 0114e00618 | |||
| 2794835590 | |||
| 193affca27 | |||
|
|
4c989ef1b3 | ||
|
|
a1758a9074 | ||
|
|
afe72b9ee1 | ||
|
|
cf1114e8d8 | ||
|
|
02b9c2411f | ||
|
|
2dca3654a8 | ||
|
|
51a26ad120 | ||
|
|
6a0347fd22 | ||
|
|
edb08e3e5c | ||
|
|
700953194a | ||
|
|
f315ff1dc1 | ||
|
|
cf0b72269c | ||
|
|
723f932356 | ||
|
|
ca7632bf25 | ||
|
|
7996f31f32 | ||
|
|
0e33835ad6 | ||
|
|
60e462e987 | ||
|
|
de3fc810ba | ||
|
|
6f4fa72a03 | ||
|
|
f5a62783ff | ||
|
|
acd5a0e2af | ||
|
|
41e8735720 | ||
|
|
fa899e5b58 | ||
|
|
9430abfd32 | ||
|
|
a97dec15c1 | ||
|
|
f6cacd29ff | ||
|
|
b6f5ebe0e6 | ||
|
|
3f9b710555 | ||
|
|
d2d482768c | ||
|
|
b7e0a52e9a | ||
|
|
ba6b791362 | ||
|
|
f09b59c3d3 | ||
|
|
5bfd6530f0 | ||
|
|
ce41c39df5 | ||
|
|
af9bd1ae44 | ||
|
|
2f53ee3997 | ||
|
|
2dc14a37b7 | ||
|
|
3863f33e23 | ||
|
|
01cf8ad11b | ||
|
|
edfc04b3c2 | ||
|
|
21e39e9268 | ||
|
|
3aecadd755 | ||
|
|
e0585422f2 | ||
|
|
252f1ecb27 | ||
|
|
ccd6d51bf3 | ||
|
|
328b0ece6a | ||
|
|
cf74f06de0 | ||
|
|
5befd30eb8 | ||
|
|
cbd4871780 | ||
|
|
cc91d89b8c | ||
|
|
5f5441c52c | ||
|
|
01964bf1f7 | ||
|
|
8cab02a627 | ||
|
|
c470d41ce3 | ||
|
|
53606f9373 | ||
|
|
1656bfb617 | ||
|
|
cc8c3a7579 | ||
|
|
251e33e8ac | ||
|
|
986b67226f | ||
|
|
ca164dd259 | ||
|
|
d7bf6e8135 | ||
|
|
48d255dbe9 | ||
|
|
35c8f721ed | ||
|
|
97df622661 | ||
|
|
c0deb9a6ea | ||
|
|
c68ad9704e | ||
|
|
c7dda1f142 | ||
|
|
10f05d75d2 | ||
|
|
58a207222c | ||
|
|
cb915afb9f | ||
|
|
1ab2c1bc06 | ||
|
|
25a671535a | ||
|
|
962b6d090d | ||
|
|
82f8cbbfe7 | ||
|
|
1c3476aa05 | ||
|
|
e2fa126c2a | ||
|
|
2fdd433efb | ||
|
|
489b7a1a04 | ||
|
|
0b14e802b5 | ||
|
|
1e341f051f | ||
|
|
5e974ea9c7 | ||
|
|
e9e5adce42 | ||
|
|
68bc636af8 | ||
|
|
bae0d70199 | ||
|
|
964254005d |
6898
calculators_list.md
6898
calculators_list.md
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
||||
"start": "node build",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint:3col": "node scripts/lint-3col.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
|
||||
36
hdyc-svelte/scripts/lint-3col.mjs
Executable file
36
hdyc-svelte/scripts/lint-3col.mjs
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const file = path.resolve('src/lib/data/calculators.ts');
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const start = text.indexOf('[');
|
||||
const end = text.indexOf('];', start);
|
||||
if (start === -1 || end === -1) {
|
||||
console.error('Unable to locate calculators array in calculators.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
let arr = text.slice(start, end + 1).trim();
|
||||
arr = arr.replace(/,\s*\]$/, ']');
|
||||
let calculators;
|
||||
try {
|
||||
calculators = JSON.parse(arr);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse calculators.ts as JSON:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const offenders = calculators.filter(c =>
|
||||
['3col', '3col-mul'].includes(c.type) &&
|
||||
c.name.toLowerCase().includes(' to ') &&
|
||||
(!c.labels?.in3 || c.labels.in3.toLowerCase() === 'result')
|
||||
);
|
||||
|
||||
if (offenders.length) {
|
||||
console.error(`3-col calculators with vague or missing output labels: ${offenders.length}`);
|
||||
offenders.slice(0, 20).forEach(c => console.error(`- ${c.slug}`));
|
||||
if (offenders.length > 20) console.error(`…and ${offenders.length - 20} more`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('3-col label lint passed.');
|
||||
15730
hdyc-svelte/sitemap.xml
Normal file
15730
hdyc-svelte/sitemap.xml
Normal file
File diff suppressed because it is too large
Load Diff
116
hdyc-svelte/sitemap.xsd
Normal file
116
hdyc-svelte/sitemap.xsd
Normal file
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
elementFormDefault="qualified">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
XML Schema for Sitemap files.
|
||||
Last Modifed 2008-03-26
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
|
||||
<xsd:element name="urlset">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
Container for a set of up to 50,000 document elements.
|
||||
This is the root element of the XML file.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
|
||||
<xsd:element name="url" type="tUrl" maxOccurs="unbounded"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="tUrl">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
Container for the data needed to describe a document to crawl.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="loc" type="tLoc"/>
|
||||
<xsd:element name="lastmod" type="tLastmod" minOccurs="0"/>
|
||||
<xsd:element name="changefreq" type="tChangeFreq" minOccurs="0"/>
|
||||
<xsd:element name="priority" type="tPriority" minOccurs="0"/>
|
||||
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:simpleType name="tLoc">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
REQUIRED: The location URI of a document.
|
||||
The URI must conform to RFC 2396 (http://www.ietf.org/rfc/rfc2396.txt).
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:restriction base="xsd:anyURI">
|
||||
<xsd:minLength value="12"/>
|
||||
<xsd:maxLength value="2048"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="tLastmod">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
OPTIONAL: The date the document was last modified. The date must conform
|
||||
to the W3C DATETIME format (http://www.w3.org/TR/NOTE-datetime).
|
||||
Example: 2005-05-10
|
||||
Lastmod may also contain a timestamp.
|
||||
Example: 2005-05-10T17:33:30+08:00
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:union>
|
||||
<xsd:simpleType>
|
||||
<xsd:restriction base="xsd:date"/>
|
||||
</xsd:simpleType>
|
||||
<xsd:simpleType>
|
||||
<xsd:restriction base="xsd:dateTime"/>
|
||||
</xsd:simpleType>
|
||||
</xsd:union>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="tChangeFreq">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
OPTIONAL: Indicates how frequently the content at a particular URL is
|
||||
likely to change. The value "always" should be used to describe
|
||||
documents that change each time they are accessed. The value "never"
|
||||
should be used to describe archived URLs. Please note that web
|
||||
crawlers may not necessarily crawl pages marked "always" more often.
|
||||
Consider this element as a friendly suggestion and not a command.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="always"/>
|
||||
<xsd:enumeration value="hourly"/>
|
||||
<xsd:enumeration value="daily"/>
|
||||
<xsd:enumeration value="weekly"/>
|
||||
<xsd:enumeration value="monthly"/>
|
||||
<xsd:enumeration value="yearly"/>
|
||||
<xsd:enumeration value="never"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="tPriority">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
OPTIONAL: The priority of a particular URL relative to other pages
|
||||
on the same site. The value for this element is a number between
|
||||
0.0 and 1.0 where 0.0 identifies the lowest priority page(s).
|
||||
The default priority of a page is 0.5. Priority is used to select
|
||||
between pages on your site. Setting a priority of 1.0 for all URLs
|
||||
will not help you, as the relative priority of pages on your site
|
||||
is what will be considered.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:restriction base="xsd:decimal">
|
||||
<xsd:minInclusive value="0.0"/>
|
||||
<xsd:maxInclusive value="1.0"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
</xsd:schema>
|
||||
@@ -10,7 +10,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('/fonts/inter/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('/fonts/inter/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('/fonts/inter/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -343,11 +343,16 @@ a:focus-visible {
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.site-header {
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -357,7 +362,7 @@ a:focus-visible {
|
||||
.site-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
@@ -640,10 +645,12 @@ a:focus-visible {
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(132px, auto);
|
||||
gap: clamp(0.75rem, 1.3vw, 1.25rem);
|
||||
}
|
||||
.category-grid .category-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@@ -662,11 +669,17 @@ a:focus-visible {
|
||||
|
||||
.calc-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
width: 100%;
|
||||
max-width: calc(4 * 360px + 3 * 0.75rem);
|
||||
margin: 0 auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.calc-list-item {
|
||||
display: block;
|
||||
position: relative;
|
||||
--calc-tooltip-left: 50%;
|
||||
--calc-tooltip-translate: -0.35rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -682,11 +695,40 @@ a:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
color: var(--accent);
|
||||
z-index: 20;
|
||||
}
|
||||
.calc-list-item:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
z-index: 20;
|
||||
}
|
||||
.calc-list-tooltip {
|
||||
position: absolute;
|
||||
bottom: var(--calc-tooltip-bottom, calc(100% + 0.4rem));
|
||||
top: var(--calc-tooltip-top, auto);
|
||||
left: var(--calc-tooltip-left, 50%);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 92%, black 8%);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translate(-50%, calc(var(--calc-tooltip-translate, -0.35rem) + 0.15rem));
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
.calc-list-item:hover .calc-list-tooltip,
|
||||
.calc-list-item:focus-visible .calc-list-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate(-50%, var(--calc-tooltip-translate, -0.35rem));
|
||||
}
|
||||
|
||||
/* ─── Related Converters ─────────────────────────────────── */
|
||||
@@ -856,14 +898,24 @@ a:focus-visible {
|
||||
}
|
||||
.category-section__inner {
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.category-section .section-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.category-grid {
|
||||
width: min(960px, 100%);
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
margin-inline: auto;
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
gap: clamp(0.7rem, 3vw, 1rem);
|
||||
justify-content: center;
|
||||
}
|
||||
.category-grid .category-card {
|
||||
flex: 0 1 clamp(150px, 40vw, 200px);
|
||||
min-width: 150px;
|
||||
max-width: 200px;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.stats-row {
|
||||
gap: 1.5rem;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="en" data-theme="dark" data-palette="classic">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter/Inter-Regular.woff2"
|
||||
@@ -10,27 +12,6 @@
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter/Inter-Medium.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter/Inter-SemiBold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter/Inter-Bold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter/Inter-ExtraBold.woff2"
|
||||
@@ -38,27 +19,25 @@
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<style>
|
||||
/* Critical CSS – inlined to eliminate render-blocking stylesheet for FCP/LCP */
|
||||
:root{--bg:#0c0f14;--bg-elevated:#12161e;--card-bg:rgba(18,22,30,.85);--border:rgba(255,255,255,.08);--text:#e8ecf4;--text-muted:#7b8498;--accent:#10b981;--accent-dark:#059669;--accent-glow:rgba(16,185,129,.15);--accent-gradient:linear-gradient(135deg,#10b981,#06b6d4);--header-bg:rgba(12,15,20,.85);--header-h:64px;--font-body:'Inter',-apple-system,BlinkMacSystemFont,sans-serif}
|
||||
:root[data-theme='light']{--bg:#f8fafc;--bg-elevated:#fff;--card-bg:#fff;--border:rgba(15,23,42,.12);--text:#0f172a;--text-muted:#475569;--accent:#047857;--accent-dark:#065f46;--accent-glow:rgba(16,185,129,.15);--accent-gradient:linear-gradient(135deg,#10b981,#06b6d4);--header-bg:rgba(255,255,255,.95)}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html{height:100%;-webkit-text-size-adjust:100%}
|
||||
body{min-height:100vh;font-family:var(--font-body);background:var(--bg);color:var(--text);line-height:1.6}
|
||||
.site-header{position:sticky;top:0;z-index:50;height:var(--header-h);display:flex;align-items:center;justify-content:space-between;padding:0 1.5rem;background:var(--header-bg);border-bottom:1px solid var(--border)}
|
||||
.header-left{display:flex;align-items:center;gap:.75rem}
|
||||
.site-logo{display:flex;align-items:center;gap:.3rem;text-decoration:none;color:var(--text);font-weight:800;font-size:1.15rem;letter-spacing:-.02em;white-space:nowrap}
|
||||
.site-logo .logo-accent{color:var(--accent)}.site-logo .logo-domain{color:var(--text-muted);font-weight:500}
|
||||
.hero{text-align:center;padding:3rem 1rem 2rem;margin-bottom:1rem}
|
||||
.hero h1{font-size:2.5rem;font-weight:800;letter-spacing:-.03em;line-height:1.15;margin-bottom:.75rem;background:var(--accent-gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.hero p{color:var(--text-muted);font-size:1.1rem;max-width:500px;margin:0 auto 1.5rem}
|
||||
.stats-row{display:flex;justify-content:center;gap:2.5rem;margin-bottom:2.5rem;padding:1rem 0}
|
||||
.stat-num{font-size:1.8rem;font-weight:800;color:var(--accent)}
|
||||
.stat-label{font-size:.78rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em}
|
||||
.main-content{flex:1;width:100%;max-width:900px;margin:0 auto;padding:2rem 1.25rem 3rem}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const doc = document.documentElement;
|
||||
@@ -77,8 +56,9 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
<!-- SvelteKit head tags moved to top of <head> -->
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
@@ -13,7 +13,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
'.otf': 'font/otf'
|
||||
};
|
||||
|
||||
const HTML_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
|
||||
const HTML_CACHE_CONTROL = 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400';
|
||||
const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
||||
const ASSET_404_CACHE_CONTROL = 'no-store';
|
||||
const LONG_CACHE_EXTENSIONS = new Set([
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { solve } from '$lib/engine';
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { getConversionRateText } from '$lib/utils/conversionRate';
|
||||
import QuickDefinitionCard from '$lib/components/QuickDefinitionCard.svelte';
|
||||
import QuickConversionExample from '$lib/components/QuickConversionExample.svelte';
|
||||
import QuickConversionTable from '$lib/components/QuickConversionTable.svelte';
|
||||
@@ -14,18 +16,34 @@
|
||||
let val2 = '';
|
||||
let val3 = '';
|
||||
let activeField: 1 | 2 | 3 = 1;
|
||||
let swapState: { originalField: 1 | 2; originalValue: string } | null = null;
|
||||
let swapState: { originalField: 1 | 2; originalValue: string | number | null } | null = null;
|
||||
let copyStatus: 'idle' | 'copied' | 'failed' = 'idle';
|
||||
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let tooltipFadeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let tooltipHideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let showCopyTooltip = false;
|
||||
let isTooltipFading = false;
|
||||
let showHoverTooltip = false;
|
||||
let footerControlsEl: HTMLDivElement | null = null;
|
||||
let tooltipX = 20;
|
||||
let copyStatusMessage = '';
|
||||
let initializedSlug: string | null = null;
|
||||
let conversionRateText: string | null = null;
|
||||
|
||||
$: has3 = ['3col', '3col-mul'].includes(config.type) || !!config.labels.in3;
|
||||
$: isTextInput = ['base', 'text-bin', 'bin-text', 'dec-frac', 'dms-dd', 'dd-dms'].includes(config.type);
|
||||
$: conversionRateText = getConversionRateText(config);
|
||||
|
||||
// Clear inputs on config (route) change
|
||||
$: if (config) {
|
||||
if (!paramsInitializing) clear();
|
||||
// Clear inputs only when navigating to a different calculator slug.
|
||||
$: if (config?.slug) {
|
||||
if (initializedSlug === null) {
|
||||
initializedSlug = config.slug;
|
||||
} else if (initializedSlug !== config.slug) {
|
||||
initializedSlug = config.slug;
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
let paramsInitializing = true;
|
||||
|
||||
function handleInput(source: 1 | 2 | 3, options?: { preserveSwap?: boolean }) {
|
||||
if (!options?.preserveSwap) {
|
||||
swapState = null;
|
||||
@@ -64,12 +82,147 @@
|
||||
swapState = null;
|
||||
}
|
||||
|
||||
function buildShareUrl() {
|
||||
const params = new URLSearchParams();
|
||||
const v1 = toQueryValue(val1);
|
||||
const v2 = toQueryValue(val2);
|
||||
const v3 = toQueryValue(val3);
|
||||
const source: 1 | 2 | 3 = has3 ? activeField : (activeField === 2 ? 2 : 1);
|
||||
|
||||
if (!has3) {
|
||||
const sourceValue = source === 1 ? v1 : v2;
|
||||
if (sourceValue !== null) {
|
||||
params.set(source === 1 ? 'v1' : 'v2', sourceValue);
|
||||
}
|
||||
} else if (source === 3) {
|
||||
if (v2 !== null) params.set('v2', v2);
|
||||
if (v3 !== null) params.set('v3', v3);
|
||||
} else {
|
||||
if (v1 !== null) params.set('v1', v1);
|
||||
if (v2 !== null) params.set('v2', v2);
|
||||
}
|
||||
|
||||
const shareUrl = new URL($page.url);
|
||||
shareUrl.search = params.toString();
|
||||
return shareUrl.toString();
|
||||
}
|
||||
|
||||
function toQueryValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const stringValue = String(value);
|
||||
return stringValue.trim() ? stringValue : null;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (!copied) {
|
||||
throw new Error('execCommand copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerCopyTooltip() {
|
||||
if (tooltipFadeTimeout) clearTimeout(tooltipFadeTimeout);
|
||||
if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
|
||||
showCopyTooltip = true;
|
||||
isTooltipFading = false;
|
||||
tooltipFadeTimeout = setTimeout(() => {
|
||||
isTooltipFading = true;
|
||||
}, 900);
|
||||
tooltipHideTimeout = setTimeout(() => {
|
||||
showCopyTooltip = false;
|
||||
isTooltipFading = false;
|
||||
}, 1300);
|
||||
}
|
||||
|
||||
function updateTooltipPosition(event: MouseEvent) {
|
||||
if (!footerControlsEl) return;
|
||||
const rect = footerControlsEl.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
tooltipX = Math.max(12, Math.min(rect.width - 12, x));
|
||||
}
|
||||
|
||||
function positionTooltipFromButton(button: HTMLButtonElement) {
|
||||
if (!footerControlsEl) return;
|
||||
const controlsRect = footerControlsEl.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const centerX = buttonRect.left - controlsRect.left + buttonRect.width / 2;
|
||||
tooltipX = Math.max(12, Math.min(controlsRect.width - 12, centerX));
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!browser) return;
|
||||
const url = buildShareUrl();
|
||||
|
||||
try {
|
||||
await copyText(url);
|
||||
copyStatus = 'copied';
|
||||
triggerCopyTooltip();
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link', error);
|
||||
copyStatus = 'failed';
|
||||
} finally {
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout);
|
||||
}
|
||||
statusTimeout = setTimeout(() => {
|
||||
copyStatus = 'idle';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
$: copyStatusMessage =
|
||||
copyStatus === 'copied'
|
||||
? 'Link copied to clipboard'
|
||||
: copyStatus === 'failed'
|
||||
? 'Failed to copy link'
|
||||
: '';
|
||||
|
||||
onMount(() => {
|
||||
const params = new URLSearchParams($page.url.search);
|
||||
if (params.has('v1')) { val1 = params.get('v1')!; handleInput(1); }
|
||||
else if (params.has('v2')) { val2 = params.get('v2')!; handleInput(2); }
|
||||
else if (params.has('v3') && has3) { val3 = params.get('v3')!; handleInput(3); }
|
||||
setTimeout(() => { paramsInitializing = false; }, 0);
|
||||
const hasV1 = params.has('v1');
|
||||
const hasV2 = params.has('v2');
|
||||
const hasV3 = has3 && params.has('v3');
|
||||
|
||||
if (has3 && hasV2 && hasV3) {
|
||||
val2 = params.get('v2') ?? '';
|
||||
val3 = params.get('v3') ?? '';
|
||||
handleInput(3);
|
||||
} else if (has3 && hasV1 && hasV2) {
|
||||
val1 = params.get('v1') ?? '';
|
||||
val2 = params.get('v2') ?? '';
|
||||
handleInput(1);
|
||||
} else if (hasV1) {
|
||||
val1 = params.get('v1') ?? '';
|
||||
handleInput(1);
|
||||
} else if (hasV2) {
|
||||
val2 = params.get('v2') ?? '';
|
||||
handleInput(2);
|
||||
} else if (hasV3) {
|
||||
val3 = params.get('v3') ?? '';
|
||||
handleInput(3);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (statusTimeout) clearTimeout(statusTimeout);
|
||||
if (tooltipFadeTimeout) clearTimeout(tooltipFadeTimeout);
|
||||
if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -142,12 +295,54 @@
|
||||
</div>
|
||||
|
||||
<div class="calc-footer">
|
||||
<button type="button" class="clear-btn" on:click={clear} aria-label="Clear calculator inputs">
|
||||
Clear
|
||||
</button>
|
||||
{#if config.factor && config.type === 'standard'}
|
||||
<div class="footer-controls" bind:this={footerControlsEl}>
|
||||
<button type="button" class="clear-btn" on:click={clear} aria-label="Clear calculator inputs">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
on:click={(event) => {
|
||||
positionTooltipFromButton(event.currentTarget as HTMLButtonElement);
|
||||
copyLink();
|
||||
}}
|
||||
on:mouseenter={(event) => {
|
||||
showHoverTooltip = true;
|
||||
updateTooltipPosition(event);
|
||||
}}
|
||||
on:mousemove={updateTooltipPosition}
|
||||
on:mouseleave={() => (showHoverTooltip = false)}
|
||||
on:focus={(event) => {
|
||||
showHoverTooltip = true;
|
||||
positionTooltipFromButton(event.currentTarget as HTMLButtonElement);
|
||||
}}
|
||||
on:blur={() => (showHoverTooltip = false)}
|
||||
aria-label="Copy calculator link"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" role="presentation" aria-hidden="true">
|
||||
<path
|
||||
d="M13.5 6.5l1.5-1.5a4.243 4.243 0 0 1 6 6L19.5 12.5M10.5 17.5L9 19a4.243 4.243 0 1 1-6-6L4.5 11.5M8 16l8-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showHoverTooltip && !showCopyTooltip}
|
||||
<span class="copy-tooltip hover" style={`left: ${tooltipX}px;`}>Copy link</span>
|
||||
{/if}
|
||||
{#if showCopyTooltip && copyStatus === 'copied'}
|
||||
<span class="copy-tooltip" class:fading={isTooltipFading} style={`left: ${tooltipX}px;`}>Link copied!</span>
|
||||
{/if}
|
||||
<span class="sr-only" aria-live="polite">
|
||||
{copyStatusMessage}
|
||||
</span>
|
||||
</div>
|
||||
{#if conversionRateText}
|
||||
<span class="formula-hint">
|
||||
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
|
||||
{conversionRateText}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -284,6 +479,13 @@
|
||||
padding: 1rem 2rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.footer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
@@ -303,6 +505,69 @@
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--section-bg);
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.icon-btn svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn:not(:disabled):hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.copy-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.4rem);
|
||||
background: color-mix(in srgb, var(--accent) 90%, black 10%);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
.copy-tooltip.hover {
|
||||
background: var(--section-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.copy-tooltip.fading {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -0.2rem);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.formula-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
min-height: 132px;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -22,8 +24,6 @@
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.category-card:hover {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { solve } from '$lib/engine';
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import { formatConversionValue } from '$lib/utils/formatConversionValue';
|
||||
|
||||
export let config: CalculatorDef;
|
||||
|
||||
@@ -17,40 +18,29 @@
|
||||
? solve(config, 1, exampleInput.toString(), '', '')
|
||||
: null;
|
||||
$: offset = config.offset ?? 0;
|
||||
$: formulaExpression = supportsExample
|
||||
? `${exampleInput} × ${config.factor}${offset ? ` + ${offset}` : ''}`
|
||||
$: hasOffset = Boolean(offset);
|
||||
$: formattedFactorValue = supportsExample
|
||||
? formatConversionValue(config.factor)
|
||||
: '';
|
||||
$: formattedOffsetValue = hasOffset
|
||||
? formatConversionValue(offset)
|
||||
: '';
|
||||
$: formulaExpression = supportsExample
|
||||
? `${exampleInput} × ${formattedFactorValue}${hasOffset ? ` + ${formattedOffsetValue}` : ''}`
|
||||
: '';
|
||||
|
||||
const formatExampleValue = (value: number | null): string => {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
const rounded = parseFloat(value.toFixed(6));
|
||||
if (rounded !== 0) {
|
||||
return rounded.toString();
|
||||
}
|
||||
const precise = value.toFixed(12).replace(/\.?0+$/, '');
|
||||
return precise || '0';
|
||||
};
|
||||
|
||||
$: reverseExampleValue =
|
||||
supportsExample && config.factor !== 0
|
||||
? (1 - offset) / config.factor
|
||||
: null;
|
||||
$: formattedReverseValue = formatExampleValue(reverseExampleValue);
|
||||
$: formattedReverseValue = formatConversionValue(reverseExampleValue);
|
||||
</script>
|
||||
|
||||
{#if supportsExample && result}
|
||||
<section class="example-card">
|
||||
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
|
||||
1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} {config.labels.in2}
|
||||
</p>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
type Row = { input: number; output: string };
|
||||
|
||||
const buildRow = (value: number): Row => {
|
||||
const formatted = solve(config, 1, value.toString(), '', '');
|
||||
const buildRow = (value: number, c: CalculatorDef): Row => {
|
||||
const formatted = solve(c, 1, value.toString(), '', '');
|
||||
return {
|
||||
input: value,
|
||||
output: formatted.val2 || '—'
|
||||
output: formatted.val2 || '—',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
let outputLabel = 'target units';
|
||||
|
||||
$: supportsTable = ['standard', 'inverse'].includes(config.type);
|
||||
$: rows = supportsTable
|
||||
? numericSamples.map(buildRow)
|
||||
$: rows = (config && supportsTable)
|
||||
? numericSamples.map(v => buildRow(v, config))
|
||||
: [];
|
||||
$: inputLabel = config.labels?.in1 ?? 'source units';
|
||||
$: outputLabel = config.labels?.in2 ?? 'target units';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getDefinition } from '$lib/data/unitDefinitions';
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import type { CalculatorDef } from '$lib/data/calculatorLoader';
|
||||
|
||||
export let config: CalculatorDef;
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
|
||||
$: label1 = config.labels.in1 || 'Unit 1';
|
||||
$: label2 = config.labels.in2 || 'Unit 2';
|
||||
$: def1 = getDefinition(label1, config.category);
|
||||
$: def2 = getDefinition(label2, config.category);
|
||||
$: {
|
||||
getDefinition(label1, config.category).then(d => { def1 = d; });
|
||||
getDefinition(label2, config.category).then(d => { def2 = d; });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="definition-card">
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { searchCalculators } from '$lib/data/calculators';
|
||||
import { goto } from '$app/navigation';
|
||||
import { loadCalculators, searchCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
|
||||
|
||||
export let idPrefix = 'search';
|
||||
|
||||
let query = '';
|
||||
let focused = false;
|
||||
let selectedIndex = -1;
|
||||
let lastQuery = '';
|
||||
let allCalcs: CalculatorDef[] = [];
|
||||
|
||||
$: results = query.length >= 2 ? searchCalculators(query).slice(0, 8) : [];
|
||||
$: if (query.length >= 1 && allCalcs.length === 0) {
|
||||
loadCalculators().then(data => { allCalcs = data; });
|
||||
}
|
||||
|
||||
$: results = (query.length >= 2 && allCalcs.length > 0) ? searchCalculators(allCalcs, query).slice(0, 8) : [];
|
||||
$: listboxId = `${idPrefix}-listbox`;
|
||||
$: inputId = `${idPrefix}-input`;
|
||||
$: isOpen = focused && results.length > 0;
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { categories, getCalculatorsByCategory, type CalculatorDef } from '$lib/data/calculators';
|
||||
import { categories } from '$lib/data/stats';
|
||||
import { loadCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
|
||||
|
||||
let allCalculators: CalculatorDef[] = [];
|
||||
let isLoaded = false;
|
||||
|
||||
async function loadData() {
|
||||
if (isLoaded || !browser) return;
|
||||
const data = await loadCalculators();
|
||||
allCalculators = data;
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
let expandedCategory = '';
|
||||
let expandedUnits: Record<string, string> = {};
|
||||
@@ -23,50 +34,85 @@
|
||||
|
||||
type UnitGroup = {
|
||||
label: string;
|
||||
conversions: CalculatorDef[];
|
||||
conversions: UnitConversionLink[];
|
||||
};
|
||||
|
||||
type UnitBucket = {
|
||||
label: string;
|
||||
conversions: CalculatorDef[];
|
||||
conversions: UnitConversionLink[];
|
||||
};
|
||||
|
||||
const sortConversionsForUnit = (conversions: CalculatorDef[], unitLabel: string) => {
|
||||
const normalizedUnit = unitLabel.toLowerCase();
|
||||
return conversions.slice().sort((a, b) => {
|
||||
const aIsSource = a.labels.in1?.toLowerCase() === normalizedUnit;
|
||||
const bIsSource = b.labels.in1?.toLowerCase() === normalizedUnit;
|
||||
if (aIsSource !== bIsSource) {
|
||||
return aIsSource ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
type UnitConversionLink = {
|
||||
name: string;
|
||||
slug: string;
|
||||
sortKey: string;
|
||||
};
|
||||
|
||||
const sortConversionsForUnit = (conversions: UnitConversionLink[]) =>
|
||||
conversions.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const toPairKey = (unitA: string, unitB: string) =>
|
||||
[unitA.toLowerCase(), unitB.toLowerCase()].sort().join('::');
|
||||
|
||||
const toDirectionKey = (fromUnit: string, toUnit: string) =>
|
||||
`${fromUnit.toLowerCase()}::${toUnit.toLowerCase()}`;
|
||||
|
||||
function addConversion(
|
||||
buckets: Map<string, UnitBucket>,
|
||||
fromUnit: string,
|
||||
toUnit: string,
|
||||
slug: string
|
||||
) {
|
||||
const bucketKey = fromUnit.toLowerCase();
|
||||
const directionKey = toDirectionKey(fromUnit, toUnit);
|
||||
const conversion: UnitConversionLink = {
|
||||
name: `${fromUnit} to ${toUnit}`,
|
||||
slug,
|
||||
sortKey: directionKey,
|
||||
};
|
||||
|
||||
const existing = buckets.get(bucketKey);
|
||||
if (existing) {
|
||||
if (!existing.conversions.some(link => link.sortKey === directionKey)) {
|
||||
existing.conversions.push(conversion);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buckets.set(bucketKey, {
|
||||
label: fromUnit,
|
||||
conversions: [conversion],
|
||||
});
|
||||
}
|
||||
|
||||
$: categoryUnitGroups = Object.entries(categories).map(([key, meta]) => {
|
||||
const buckets = new Map<string, UnitBucket>();
|
||||
const calcs = getCalculatorsByCategory(key);
|
||||
|
||||
if (!isLoaded) {
|
||||
return { key, meta, units: [] };
|
||||
}
|
||||
|
||||
const calcs = allCalculators.filter(c => c.category === key && !c.hidden);
|
||||
const canonicalByPair = new Map<string, CalculatorDef>();
|
||||
|
||||
calcs.forEach(calc => {
|
||||
[calc.labels.in1, calc.labels.in2].forEach(unit => {
|
||||
const key = unit.toLowerCase();
|
||||
const existing = buckets.get(key);
|
||||
if (existing) {
|
||||
existing.conversions.push(calc);
|
||||
} else {
|
||||
buckets.set(key, {
|
||||
label: unit,
|
||||
conversions: [calc],
|
||||
});
|
||||
}
|
||||
});
|
||||
const pairKey = toPairKey(calc.labels.in1, calc.labels.in2);
|
||||
const existing = canonicalByPair.get(pairKey);
|
||||
if (!existing || calc.slug.localeCompare(existing.slug) < 0) {
|
||||
canonicalByPair.set(pairKey, calc);
|
||||
}
|
||||
});
|
||||
|
||||
canonicalByPair.forEach(calc => {
|
||||
addConversion(buckets, calc.labels.in1, calc.labels.in2, calc.slug);
|
||||
addConversion(buckets, calc.labels.in2, calc.labels.in1, calc.slug);
|
||||
});
|
||||
|
||||
const units = [...buckets.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, bucket]) => ({
|
||||
label: bucket.label,
|
||||
conversions: sortConversionsForUnit(bucket.conversions, bucket.label),
|
||||
conversions: sortConversionsForUnit(bucket.conversions),
|
||||
}));
|
||||
|
||||
return { key, meta, units };
|
||||
@@ -131,6 +177,11 @@
|
||||
}
|
||||
|
||||
export let open = false;
|
||||
|
||||
$: if (browser && (isDesktop || open)) {
|
||||
loadData();
|
||||
}
|
||||
|
||||
$: isSidebarHidden = !isDesktop && !open;
|
||||
|
||||
function closeSidebar() {
|
||||
@@ -188,14 +239,14 @@
|
||||
</button>
|
||||
{#if expandedUnits[group.key] === unit.label}
|
||||
<ul class="unit-list">
|
||||
{#each unit.conversions as calc}
|
||||
{#each unit.conversions as conversion}
|
||||
<li>
|
||||
<a
|
||||
href="/{calc.slug}"
|
||||
class:current={currentPath === `/${calc.slug}`}
|
||||
aria-current={currentPath === `/${calc.slug}` ? 'page' : undefined}
|
||||
href="/{conversion.slug}"
|
||||
class:current={currentPath === `/${conversion.slug}`}
|
||||
aria-current={currentPath === `/${conversion.slug}` ? 'page' : undefined}
|
||||
>
|
||||
{calc.name}
|
||||
{conversion.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
53
hdyc-svelte/src/lib/data/calculatorLoader.ts
Normal file
53
hdyc-svelte/src/lib/data/calculatorLoader.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Shared lazy loader – fetches /data/calculators.json exactly once.
|
||||
// Because this is a plain fetch (not a JS dynamic import), Vite will NOT
|
||||
// emit a modulepreload for it, keeping the homepage bundle small.
|
||||
|
||||
export interface CalculatorDef {
|
||||
slug: string;
|
||||
name: string;
|
||||
category: string;
|
||||
type: string;
|
||||
teaser: string;
|
||||
labels: { in1: string; in2: string };
|
||||
factor?: number;
|
||||
offset?: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
let cache: CalculatorDef[] | null = null;
|
||||
let pending: Promise<CalculatorDef[]> | null = null;
|
||||
|
||||
const runtimeHost =
|
||||
import.meta.env.PUBLIC_SITE_URL ??
|
||||
(import.meta.env.DEV ? 'http://localhost:5173' : 'https://howdoyouconvert.com');
|
||||
|
||||
const getCalculatorsUrl = (): string =>
|
||||
import.meta.env.SSR
|
||||
? new URL('/data/calculators.json', runtimeHost).toString()
|
||||
: '/data/calculators.json';
|
||||
|
||||
export async function loadCalculators(): Promise<CalculatorDef[]> {
|
||||
if (cache) return cache;
|
||||
if (pending) return pending;
|
||||
|
||||
const url = getCalculatorsUrl();
|
||||
pending = fetch(url)
|
||||
.then(r => r.json())
|
||||
.then((data: CalculatorDef[]) => {
|
||||
cache = data;
|
||||
pending = null;
|
||||
return data;
|
||||
});
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function searchCalculators(calcs: CalculatorDef[], query: string): CalculatorDef[] {
|
||||
const q = query.toLowerCase();
|
||||
return calcs.filter(c =>
|
||||
(c.name.toLowerCase().includes(q) ||
|
||||
c.slug.includes(q) ||
|
||||
c.labels.in1.toLowerCase().includes(q) ||
|
||||
c.labels.in2.toLowerCase().includes(q)) && !c.hidden
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
85
hdyc-svelte/src/lib/data/stats.ts
Normal file
85
hdyc-svelte/src/lib/data/stats.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// THIS FILE IS AUTO-GENERATED BY migrate.py
|
||||
export const categories: Record<string, { label: string; icon: string }> = {
|
||||
"length": {
|
||||
"label": "Length / Distance",
|
||||
"icon": "📏"
|
||||
},
|
||||
"weight": {
|
||||
"label": "Weight / Mass",
|
||||
"icon": "⚖️"
|
||||
},
|
||||
"temperature": {
|
||||
"label": "Temperature",
|
||||
"icon": "🌡️"
|
||||
},
|
||||
"volume": {
|
||||
"label": "Volume",
|
||||
"icon": "🧪"
|
||||
},
|
||||
"fluids": {
|
||||
"label": "Fluids",
|
||||
"icon": "💧"
|
||||
},
|
||||
"area": {
|
||||
"label": "Area",
|
||||
"icon": "🔳"
|
||||
},
|
||||
"speed": {
|
||||
"label": "Speed / Velocity",
|
||||
"icon": "💨"
|
||||
},
|
||||
"pressure": {
|
||||
"label": "Pressure",
|
||||
"icon": "🔽"
|
||||
},
|
||||
"energy": {
|
||||
"label": "Energy",
|
||||
"icon": "⚡"
|
||||
},
|
||||
"magnetism": {
|
||||
"label": "Magnetism",
|
||||
"icon": "🧲"
|
||||
},
|
||||
"power": {
|
||||
"label": "Power",
|
||||
"icon": "🔌"
|
||||
},
|
||||
"data": {
|
||||
"label": "Data Storage",
|
||||
"icon": "💾"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time",
|
||||
"icon": "⏱️"
|
||||
},
|
||||
"angle": {
|
||||
"label": "Angle",
|
||||
"icon": "📐"
|
||||
},
|
||||
"number-systems": {
|
||||
"label": "Number Systems",
|
||||
"icon": "🔢"
|
||||
},
|
||||
"radiation": {
|
||||
"label": "Radiation",
|
||||
"icon": "☢️"
|
||||
},
|
||||
"electrical": {
|
||||
"label": "Electrical",
|
||||
"icon": "🔋"
|
||||
},
|
||||
"force": {
|
||||
"label": "Force / Torque",
|
||||
"icon": "💪"
|
||||
},
|
||||
"light": {
|
||||
"label": "Light",
|
||||
"icon": "💡"
|
||||
},
|
||||
"other": {
|
||||
"label": "Other",
|
||||
"icon": "🔄"
|
||||
}
|
||||
};
|
||||
|
||||
export const totalCalculators = 3124;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { calculators } from './calculators';
|
||||
import { loadCalculators, type CalculatorDef } from './calculatorLoader';
|
||||
|
||||
const domainDefinitions: Record<string, { summary: string; context: string }> = {
|
||||
length: {
|
||||
@@ -97,7 +97,6 @@ const normalizeLabel = (label?: string): string | undefined => {
|
||||
return alias ?? trimmed;
|
||||
};
|
||||
|
||||
const definitions: Record<string, Record<string, string>> = {};
|
||||
const categoryPriority = [...Object.keys(domainDefinitions)];
|
||||
|
||||
const buildDefinition = (label: string, categoryKey: string): string => {
|
||||
@@ -107,17 +106,36 @@ const buildDefinition = (label: string, categoryKey: string): string => {
|
||||
return `${label} ${description}`;
|
||||
};
|
||||
|
||||
calculators.forEach(calc => {
|
||||
const { category, labels } = calc;
|
||||
Object.values(labels).forEach(label => {
|
||||
const normalized = normalizeLabel(label);
|
||||
if (!normalized) return;
|
||||
const bucket = definitions[normalized] || {};
|
||||
const text = buildDefinition(normalized, category);
|
||||
bucket[category] = text;
|
||||
definitions[normalized] = bucket;
|
||||
// Lazily built definitions cache
|
||||
let definitions: Record<string, Record<string, string>> | null = null;
|
||||
let buildPromise: Promise<void> | null = null;
|
||||
|
||||
async function ensureBuilt(): Promise<Record<string, Record<string, string>>> {
|
||||
if (definitions) return definitions;
|
||||
if (buildPromise) {
|
||||
await buildPromise;
|
||||
return definitions!;
|
||||
}
|
||||
|
||||
buildPromise = loadCalculators().then(calculators => {
|
||||
const defs: Record<string, Record<string, string>> = {};
|
||||
calculators.forEach(calc => {
|
||||
const { category, labels } = calc;
|
||||
Object.values(labels).forEach(label => {
|
||||
const normalized = normalizeLabel(label);
|
||||
if (!normalized) return;
|
||||
const bucket = defs[normalized] || {};
|
||||
const text = buildDefinition(normalized, category);
|
||||
bucket[category] = text;
|
||||
defs[normalized] = bucket;
|
||||
});
|
||||
});
|
||||
definitions = defs;
|
||||
});
|
||||
});
|
||||
|
||||
await buildPromise;
|
||||
return definitions!;
|
||||
}
|
||||
|
||||
const findByPriority = (entries: Record<string, string>, preferred?: string): string | undefined => {
|
||||
if (!entries) return undefined;
|
||||
@@ -129,11 +147,10 @@ const findByPriority = (entries: Record<string, string>, preferred?: string): st
|
||||
return fallback.length ? fallback[0] : undefined;
|
||||
};
|
||||
|
||||
export function getDefinition(label: string, category?: string): string | undefined {
|
||||
export async function getDefinition(label: string, category?: string): Promise<string | undefined> {
|
||||
const normalized = normalizeLabel(label);
|
||||
if (!normalized) return undefined;
|
||||
const entries = definitions[normalized];
|
||||
const defs = await ensureBuilt();
|
||||
const entries = defs[normalized];
|
||||
return findByPriority(entries, category);
|
||||
}
|
||||
|
||||
export const unitDefinitions = definitions;
|
||||
|
||||
@@ -10,7 +10,13 @@ export interface SolveResult {
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return parseFloat(n.toFixed(6)).toString();
|
||||
if (!Number.isFinite(n)) return n.toString();
|
||||
if (n === 0) return '0';
|
||||
if (Math.abs(n) < 1e-6) {
|
||||
return n.toExponential(6);
|
||||
}
|
||||
const rounded = parseFloat(n.toFixed(6));
|
||||
return rounded.toString();
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
@@ -129,26 +135,47 @@ export function solve(
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dec-frac':
|
||||
if (source === 1) {
|
||||
if (!isNaN(v1)) {
|
||||
const parts = v1.toString().split('.');
|
||||
const len = parts[1] ? parts[1].length : 0;
|
||||
const den = Math.pow(10, len);
|
||||
const num = v1 * den;
|
||||
const div = gcd(num, den);
|
||||
out.val2 = `${num / div}/${den / div}`;
|
||||
} else { out.val2 = ''; }
|
||||
} else {
|
||||
const parts = rawVal2.split('/');
|
||||
case 'dec-frac': {
|
||||
// Two calculators share this type:
|
||||
// - decimal -> fraction
|
||||
// - fraction -> decimal
|
||||
// Detect which direction the left field represents via its label.
|
||||
const fractionFirst = calc.labels.in1.toLowerCase().includes('fraction');
|
||||
|
||||
const decimalToFraction = (n: number) => {
|
||||
if (isNaN(n)) return '';
|
||||
const parts = n.toString().split('.');
|
||||
const len = parts[1] ? parts[1].length : 0;
|
||||
const den = Math.pow(10, len);
|
||||
const num = n * den;
|
||||
const div = gcd(num, den);
|
||||
return `${num / div}/${den / div}`;
|
||||
};
|
||||
|
||||
const fractionToDecimal = (raw: string) => {
|
||||
const parts = raw.split('/');
|
||||
if (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1])) && Number(parts[1]) !== 0) {
|
||||
out.val1 = fmt(Number(parts[0]) / Number(parts[1]));
|
||||
return fmt(Number(parts[0]) / Number(parts[1]));
|
||||
}
|
||||
const f = parseFloat(parts[0]);
|
||||
return !isNaN(f) ? f.toString() : '';
|
||||
};
|
||||
|
||||
if (fractionFirst) {
|
||||
if (source === 1) {
|
||||
out.val2 = fractionToDecimal(rawVal1);
|
||||
} else {
|
||||
const f = parseFloat(parts[0]);
|
||||
out.val1 = !isNaN(f) ? f.toString() : '';
|
||||
out.val1 = decimalToFraction(v2);
|
||||
}
|
||||
} else {
|
||||
if (source === 1) {
|
||||
out.val2 = decimalToFraction(v1);
|
||||
} else {
|
||||
out.val1 = fractionToDecimal(rawVal2);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'db-int':
|
||||
if (source === 1) {
|
||||
@@ -181,6 +208,296 @@ export function solve(
|
||||
out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2)) : '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'awg': {
|
||||
const log92 = Math.log(92);
|
||||
const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39);
|
||||
const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92;
|
||||
const awgToCircularMils = (g: number) => 1000 * Math.pow(92, (36 - g) / 19.5);
|
||||
const circularMilsToAwg = (a: number) => 36 - 19.5 * Math.log(a / 1000) / log92;
|
||||
const awgToAreaMm2 = (g: number) => {
|
||||
const d = awgToDiameterMm(g);
|
||||
return Math.PI * Math.pow(d, 2) / 4;
|
||||
};
|
||||
const areaMm2ToAwg = (a: number) => {
|
||||
const d = Math.sqrt((4 * a) / Math.PI);
|
||||
return diameterMmToAwg(d);
|
||||
};
|
||||
|
||||
const slug = calc.slug;
|
||||
const formatAwg = (g: number) => isFinite(g) ? fmt(g) : '';
|
||||
const awgIsInput = calc.labels.in1.toLowerCase().includes('awg');
|
||||
const isCircular = slug.includes('circular-mils');
|
||||
const isArea = slug.includes('square-millimeters');
|
||||
|
||||
if (isCircular) {
|
||||
if (awgIsInput) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToCircularMils(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(circularMilsToAwg(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(circularMilsToAwg(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(awgToCircularMils(v2)) : '';
|
||||
}
|
||||
} else if (isArea) {
|
||||
if (awgIsInput) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToAreaMm2(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(areaMm2ToAwg(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(areaMm2ToAwg(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(awgToAreaMm2(v2)) : '';
|
||||
}
|
||||
} else {
|
||||
// diameter in millimeters
|
||||
if (awgIsInput) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToDiameterMm(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(diameterMmToAwg(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(diameterMmToAwg(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(awgToDiameterMm(v2)) : '';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'awg-swg': {
|
||||
const log92 = Math.log(92);
|
||||
const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39);
|
||||
const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92;
|
||||
const swgTable: Record<number, number> = {
|
||||
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.385, 6: 4.877,
|
||||
7: 4.47, 8: 4.064, 9: 3.658, 10: 3.251, 11: 2.946, 12: 2.642, 13: 2.337,
|
||||
14: 2.032, 15: 1.829, 16: 1.626, 17: 1.422, 18: 1.219, 19: 1.016,
|
||||
20: 0.914, 21: 0.813, 22: 0.711, 23: 0.61, 24: 0.559, 25: 0.508,
|
||||
26: 0.457, 27: 0.417, 28: 0.376, 29: 0.345, 30: 0.315, 31: 0.294,
|
||||
32: 0.274, 33: 0.254, 34: 0.234, 35: 0.213, 36: 0.193,
|
||||
37: 0.173, 38: 0.152, 39: 0.132, 40: 0.122, 41: 0.112, 42: 0.102,
|
||||
43: 0.091, 44: 0.081, 45: 0.071, 46: 0.061, 47: 0.051, 48: 0.04,
|
||||
49: 0.03, 50: 0.025
|
||||
};
|
||||
|
||||
const nearestSwg = (diamMm: number) => {
|
||||
let bestGauge = 0;
|
||||
let bestDiff = Number.POSITIVE_INFINITY;
|
||||
for (const [gStr, d] of Object.entries(swgTable)) {
|
||||
const diff = Math.abs(diamMm - d);
|
||||
if (diff < bestDiff) { bestDiff = diff; bestGauge = Number(gStr); }
|
||||
}
|
||||
return bestGauge;
|
||||
};
|
||||
|
||||
if (source === 1) {
|
||||
// AWG -> SWG
|
||||
if (!isNaN(v1)) {
|
||||
const diam = awgToDiameterMm(v1);
|
||||
out.val2 = fmt(nearestSwg(diam));
|
||||
} else { out.val2 = ''; }
|
||||
} else {
|
||||
// SWG -> AWG
|
||||
if (!isNaN(v2)) {
|
||||
const diam = swgTable[Math.round(v2)];
|
||||
if (diam) out.val1 = fmt(diameterMmToAwg(diam));
|
||||
else out.val1 = '';
|
||||
} else { out.val1 = ''; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ev-lux': {
|
||||
const isEvFirst = calc.labels.in1.toLowerCase().includes('ev');
|
||||
const toLux = (ev: number) => 2.5 * Math.pow(2, ev);
|
||||
const toEv = (lux: number) => lux > 0 ? Math.log(lux / 2.5) / Math.log(2) : NaN;
|
||||
if (isEvFirst) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(toLux(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(toEv(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(toEv(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(toLux(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'aov': {
|
||||
const sensorWidth = 36; // mm, full-frame horizontal
|
||||
const isFocalFirst = calc.labels.in1.toLowerCase().includes('focal');
|
||||
const toAov = (f: number) => f > 0 ? (2 * Math.atan(sensorWidth / (2 * f)) * 180 / Math.PI) : NaN;
|
||||
const toFocal = (angle: number) => {
|
||||
const radians = angle * Math.PI / 180;
|
||||
return Math.tan(radians / 2) !== 0 ? sensorWidth / (2 * Math.tan(radians / 2)) : NaN;
|
||||
};
|
||||
if (isFocalFirst) {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toAov(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toFocal(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toFocal(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toAov(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'brinell-rockwell': {
|
||||
// Approximate correlation for steels:
|
||||
// BHN = (1520000 - 4500 * HRC) / (100 - HRC)^2
|
||||
if (source === 1) {
|
||||
// Brinell to Rockwell C
|
||||
if (!isNaN(v1) && v1 > 0) {
|
||||
const a = v1;
|
||||
const disc = 4500 ** 2 + 4 * a * 1070000;
|
||||
const y = (4500 + Math.sqrt(disc)) / (2 * a);
|
||||
const hrc = 100 - y;
|
||||
out.val2 = fmt(hrc);
|
||||
} else {
|
||||
out.val2 = '';
|
||||
}
|
||||
} else {
|
||||
// Rockwell C to Brinell
|
||||
if (!isNaN(v2) && v2 < 100) {
|
||||
const h = v2;
|
||||
const bhn = (1520000 - 4500 * h) / Math.pow(100 - h, 2);
|
||||
out.val1 = fmt(bhn);
|
||||
} else {
|
||||
out.val1 = '';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'molarity': {
|
||||
const m = v1; // mol/L
|
||||
const gpl = v2; // grams/L
|
||||
const molarMass = v3; // g/mol
|
||||
|
||||
if (source === 1) {
|
||||
out.val2 = (!isNaN(m) && !isNaN(molarMass)) ? fmt(m * molarMass) : '';
|
||||
} else if (source === 2) {
|
||||
out.val1 = (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) ? fmt(gpl / molarMass) : '';
|
||||
} else {
|
||||
if (!isNaN(m) && !isNaN(molarMass)) out.val2 = fmt(m * molarMass);
|
||||
else if (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) out.val1 = fmt(gpl / molarMass);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rockwell-vickers': {
|
||||
const hrcToBhn = (h: number) => (1520000 - 4500 * h) / Math.pow(100 - h, 2);
|
||||
const bhnToHrc = (b: number) => {
|
||||
const disc = 4500 ** 2 + 4 * b * 1070000;
|
||||
const y = (4500 + Math.sqrt(disc)) / (2 * b);
|
||||
return 100 - y;
|
||||
};
|
||||
const bhnToHv = (b: number) => b * 0.95;
|
||||
const hvToBhn = (hv: number) => hv / 0.95;
|
||||
|
||||
if (source === 1) {
|
||||
const hrc = v1;
|
||||
if (!isNaN(hrc) && hrc < 100) {
|
||||
const hv = bhnToHv(hrcToBhn(hrc));
|
||||
out.val2 = fmt(hv);
|
||||
} else out.val2 = '';
|
||||
} else {
|
||||
const hv = v2;
|
||||
if (!isNaN(hv) && hv > 0) {
|
||||
const bhn = hvToBhn(hv);
|
||||
out.val1 = fmt(bhnToHrc(bhn));
|
||||
} else out.val1 = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sus-cst': {
|
||||
const susToCst = (sus: number) => {
|
||||
if (sus <= 0) return NaN;
|
||||
if (sus < 100) return 0.226 * sus - 195 / sus;
|
||||
return 0.22 * sus - 135 / sus;
|
||||
};
|
||||
const cstToSus = (cst: number) => {
|
||||
if (cst <= 0) return NaN;
|
||||
const low = (cst + Math.sqrt(cst * cst + 4 * 0.226 * 195)) / (2 * 0.226);
|
||||
const high = (cst + Math.sqrt(cst * cst + 4 * 0.22 * 135)) / (2 * 0.22);
|
||||
return low < 100 ? low : high;
|
||||
};
|
||||
|
||||
if (source === 1) {
|
||||
out.val2 = !isNaN(v1) ? fmt(susToCst(v1)) : '';
|
||||
} else {
|
||||
out.val1 = !isNaN(v2) ? fmt(cstToSus(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'swg': {
|
||||
const swgTable: Record<number, number> = {
|
||||
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47,
|
||||
8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83,
|
||||
16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61,
|
||||
24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29,
|
||||
32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14,
|
||||
40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058,
|
||||
48: 0.051, 49: 0.045, 50: 0.04
|
||||
};
|
||||
|
||||
const gaugeToMm = (g: number) => swgTable[Math.round(g)];
|
||||
const mmToGauge = (mm: number) => {
|
||||
let best = -1, bestDiff = Infinity;
|
||||
for (const [gStr, diam] of Object.entries(swgTable)) {
|
||||
const diff = Math.abs(mm - diam);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
if (calc.labels.in1.toLowerCase().includes('swg')) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(gaugeToMm(v1) ?? NaN) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(mmToGauge(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(mmToGauge(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(gaugeToMm(v2) ?? NaN) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cmil-dia': {
|
||||
const cmilToMm = (c: number) => 0.0254 * Math.sqrt(c);
|
||||
const mmToCmil = (mm: number) => Math.pow(mm / 0.0254, 2);
|
||||
|
||||
if (source === 1) {
|
||||
out.val2 = (!isNaN(v1) && v1 >= 0) ? fmt(cmilToMm(v1)) : '';
|
||||
} else {
|
||||
out.val1 = (!isNaN(v2) && v2 >= 0) ? fmt(mmToCmil(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cmil-swg': {
|
||||
const swgTable: Record<number, number> = {
|
||||
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47,
|
||||
8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83,
|
||||
16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61,
|
||||
24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29,
|
||||
32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14,
|
||||
40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058,
|
||||
48: 0.051, 49: 0.045, 50: 0.04
|
||||
};
|
||||
const mmToSwg = (mm: number) => {
|
||||
let best = -1, bestDiff = Infinity;
|
||||
for (const [gStr, diam] of Object.entries(swgTable)) {
|
||||
const diff = Math.abs(mm - diam);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const swgToMm = (g: number) => swgTable[Math.round(g)];
|
||||
const cmilToMm = (c: number) => 0.0254 * Math.sqrt(c);
|
||||
const mmToCmil = (mm: number) => Math.pow(mm / 0.0254, 2);
|
||||
|
||||
if (source === 1) {
|
||||
const mm = (!isNaN(v1) && v1 >= 0) ? cmilToMm(v1) : NaN;
|
||||
out.val2 = isFinite(mm) ? fmt(mmToSwg(mm)) : '';
|
||||
} else {
|
||||
const mm = (!isNaN(v2) && v2 >= 0) ? swgToMm(v2) : NaN;
|
||||
out.val1 = isFinite(mm) ? fmt(mmToCmil(mm)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
244
hdyc-svelte/src/lib/palettes.ts
Normal file
244
hdyc-svelte/src/lib/palettes.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
export type PaletteVar =
|
||||
| 'bg'
|
||||
| 'bg-elevated'
|
||||
| 'sidebar-bg'
|
||||
| 'card-bg'
|
||||
| 'input-bg'
|
||||
| 'hover-bg'
|
||||
| 'border'
|
||||
| 'text'
|
||||
| 'text-muted'
|
||||
| 'accent'
|
||||
| 'accent-dark'
|
||||
| 'accent-glow'
|
||||
| 'accent-gradient'
|
||||
| 'header-bg';
|
||||
|
||||
export type PaletteTheme = Record<PaletteVar, string>;
|
||||
|
||||
export type Palette = {
|
||||
slug: string;
|
||||
label: string;
|
||||
light: PaletteTheme;
|
||||
dark: PaletteTheme;
|
||||
};
|
||||
|
||||
export const palettes: Palette[] = [
|
||||
{
|
||||
slug: 'classic',
|
||||
label: 'Classic',
|
||||
light: {
|
||||
bg: '#f8fafc',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#ffffff',
|
||||
'input-bg': 'rgba(15, 23, 42, 0.04)',
|
||||
'hover-bg': 'rgba(15, 23, 42, 0.08)',
|
||||
border: 'rgba(15, 23, 42, 0.12)',
|
||||
text: '#0f172a',
|
||||
'text-muted': '#475569',
|
||||
accent: '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.15)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
bg: '#0c0f14',
|
||||
'bg-elevated': '#12161e',
|
||||
'sidebar-bg': '#10141b',
|
||||
'card-bg': 'rgba(18, 22, 30, 0.85)',
|
||||
'input-bg': 'rgba(255, 255, 255, 0.04)',
|
||||
'hover-bg': 'rgba(255, 255, 255, 0.06)',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
text: '#e8ecf4',
|
||||
'text-muted': '#7b8498',
|
||||
accent: '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.15)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
|
||||
'header-bg': 'rgba(12, 15, 20, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'emerald',
|
||||
label: 'Emerald',
|
||||
light: {
|
||||
'bg': '#f6fbf9',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#ffffff',
|
||||
'input-bg': '#ecf7f1',
|
||||
'hover-bg': '#d5f0df',
|
||||
'border': 'rgba(4, 120, 87, 0.25)',
|
||||
'text': '#0b2c1f',
|
||||
'text-muted': '#4a6b5c',
|
||||
'accent': '#047857',
|
||||
'accent-dark': '#065f46',
|
||||
'accent-glow': 'rgba(4, 120, 87, 0.2)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #047857, #0ea5e9)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0b1313',
|
||||
'bg-elevated': 'rgba(4, 20, 15, 0.85)',
|
||||
'sidebar-bg': '#08110f',
|
||||
'card-bg': 'rgba(6, 19, 13, 0.75)',
|
||||
'input-bg': 'rgba(16, 185, 129, 0.08)',
|
||||
'hover-bg': 'rgba(16, 185, 129, 0.12)',
|
||||
'border': 'rgba(16, 185, 129, 0.35)',
|
||||
'text': '#e9fcea',
|
||||
'text-muted': '#9fdac4',
|
||||
'accent': '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #0ea5e9)',
|
||||
'header-bg': 'rgba(12, 15, 20, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'sunset',
|
||||
label: 'Sunset',
|
||||
light: {
|
||||
'bg': '#fff8f2',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fff4ef',
|
||||
'input-bg': '#ffe3d8',
|
||||
'hover-bg': '#ffd3bf',
|
||||
'border': 'rgba(249, 115, 22, 0.25)',
|
||||
'text': '#3d1b0b',
|
||||
'text-muted': '#7a4a37',
|
||||
'accent': '#f97316',
|
||||
'accent-dark': '#c2410c',
|
||||
'accent-glow': 'rgba(249, 115, 22, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #f97316, #ec4899)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.96)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0f0505',
|
||||
'bg-elevated': 'rgba(15, 5, 5, 0.85)',
|
||||
'sidebar-bg': '#0c0404',
|
||||
'card-bg': 'rgba(19, 6, 6, 0.7)',
|
||||
'input-bg': 'rgba(251, 113, 133, 0.08)',
|
||||
'hover-bg': 'rgba(251, 113, 133, 0.14)',
|
||||
'border': 'rgba(251, 113, 133, 0.35)',
|
||||
'text': '#ffe7e0',
|
||||
'text-muted': '#f9a6aa',
|
||||
'accent': '#fb7185',
|
||||
'accent-dark': '#be123c',
|
||||
'accent-glow': 'rgba(251, 113, 133, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fb7185, #f97316)',
|
||||
'header-bg': 'rgba(12, 8, 6, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'ocean',
|
||||
label: 'Ocean',
|
||||
light: {
|
||||
'bg': '#f4fbff',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#f0f7ff',
|
||||
'input-bg': '#dcefff',
|
||||
'hover-bg': '#cae8ff',
|
||||
'border': 'rgba(14, 165, 233, 0.25)',
|
||||
'text': '#06274e',
|
||||
'text-muted': '#4d6993',
|
||||
'accent': '#0ea5e9',
|
||||
'accent-dark': '#0369a1',
|
||||
'accent-glow': 'rgba(14, 165, 233, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #0ea5e9, #4753ff)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#030b12',
|
||||
'bg-elevated': 'rgba(2, 9, 20, 0.85)',
|
||||
'sidebar-bg': '#050c16',
|
||||
'card-bg': 'rgba(3, 13, 26, 0.75)',
|
||||
'input-bg': 'rgba(14, 165, 233, 0.08)',
|
||||
'hover-bg': 'rgba(14, 165, 233, 0.15)',
|
||||
'border': 'rgba(14, 165, 233, 0.4)',
|
||||
'text': '#e6f6ff',
|
||||
'text-muted': '#a1c4e8',
|
||||
'accent': '#38bdf8',
|
||||
'accent-dark': '#0369a1',
|
||||
'accent-glow': 'rgba(14, 165, 233, 0.35)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #38bdf8, #0f172a)',
|
||||
'header-bg': 'rgba(6, 15, 30, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'orchid',
|
||||
label: 'Orchid',
|
||||
light: {
|
||||
'bg': '#fdf6ff',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fdf2ff',
|
||||
'input-bg': '#f5e4ff',
|
||||
'hover-bg': '#e9d4ff',
|
||||
'border': 'rgba(168, 85, 247, 0.25)',
|
||||
'text': '#2c0a3a',
|
||||
'text-muted': '#6a5277',
|
||||
'accent': '#a855f7',
|
||||
'accent-dark': '#6d28d9',
|
||||
'accent-glow': 'rgba(168, 85, 247, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #c084fc, #a855f7)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.97)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0c0215',
|
||||
'bg-elevated': 'rgba(10, 3, 30, 0.85)',
|
||||
'sidebar-bg': '#090118',
|
||||
'card-bg': 'rgba(12, 2, 25, 0.75)',
|
||||
'input-bg': 'rgba(168, 85, 247, 0.08)',
|
||||
'hover-bg': 'rgba(168, 85, 247, 0.16)',
|
||||
'border': 'rgba(168, 85, 247, 0.35)',
|
||||
'text': '#f5e6ff',
|
||||
'text-muted': '#c5a3e8',
|
||||
'accent': '#d946ef',
|
||||
'accent-dark': '#831843',
|
||||
'accent-glow': 'rgba(217, 70, 239, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #d946ef, #fb7185)',
|
||||
'header-bg': 'rgba(13, 6, 23, 0.95)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'citrus',
|
||||
label: 'Citrus',
|
||||
light: {
|
||||
'bg': '#fffdf5',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fffaf0',
|
||||
'input-bg': '#fff4d8',
|
||||
'hover-bg': '#ffeec1',
|
||||
'border': 'rgba(250, 204, 21, 0.25)',
|
||||
'text': '#1f1505',
|
||||
'text-muted': '#5b4a1e',
|
||||
'accent': '#fbbf24',
|
||||
'accent-dark': '#c2410c',
|
||||
'accent-glow': 'rgba(250, 204, 21, 0.3)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #d97706)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.98)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#1a1203',
|
||||
'bg-elevated': 'rgba(26, 18, 3, 0.9)',
|
||||
'sidebar-bg': '#130e02',
|
||||
'card-bg': 'rgba(26, 18, 3, 0.75)',
|
||||
'input-bg': 'rgba(250, 204, 21, 0.08)',
|
||||
'hover-bg': 'rgba(250, 204, 21, 0.14)',
|
||||
'border': 'rgba(250, 204, 21, 0.35)',
|
||||
'text': '#fff8e7',
|
||||
'text-muted': '#f6dea1',
|
||||
'accent': '#fbbf24',
|
||||
'accent-dark': '#b45309',
|
||||
'accent-glow': 'rgba(250, 204, 21, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #f97316)',
|
||||
'header-bg': 'rgba(15, 9, 2, 0.9)',
|
||||
},
|
||||
},
|
||||
];
|
||||
21
hdyc-svelte/src/lib/utils/conversionRate.ts
Normal file
21
hdyc-svelte/src/lib/utils/conversionRate.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import { formatConversionValue } from '$lib/utils/formatConversionValue';
|
||||
|
||||
type RateConfig = Pick<CalculatorDef, 'type' | 'factor' | 'offset' | 'labels'>;
|
||||
|
||||
export const getConversionRateText = (config: RateConfig): string | null => {
|
||||
if (config.type !== 'standard' || !config.factor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { in1, in2 } = config.labels;
|
||||
if (!in1 || !in2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedFactor = formatConversionValue(config.factor);
|
||||
const hasOffset = Boolean(config.offset);
|
||||
const formattedOffset = formatConversionValue(config.offset ?? 0);
|
||||
|
||||
return `1 ${in1} = ${formattedFactor}${hasOffset ? ` + ${formattedOffset}` : ''} ${in2}`;
|
||||
};
|
||||
24
hdyc-svelte/src/lib/utils/formatConversionValue.js
Normal file
24
hdyc-svelte/src/lib/utils/formatConversionValue.js
Normal file
@@ -0,0 +1,24 @@
|
||||
function formatConversionValue(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
const rounded = parseFloat(value.toFixed(6));
|
||||
if (rounded !== 0) {
|
||||
return rounded.toString();
|
||||
}
|
||||
const precise = value.toFixed(12).replace(/\.?0+$/, '');
|
||||
if (precise !== '0' && precise !== '') {
|
||||
return precise;
|
||||
}
|
||||
|
||||
// Fallback for extremely small values: use scientific notation but clean it up
|
||||
const scientific = value.toExponential();
|
||||
return scientific.replace(/\.?0+e/, 'e');
|
||||
}
|
||||
module.exports = { formatConversionValue };
|
||||
23
hdyc-svelte/src/lib/utils/formatConversionValue.ts
Normal file
23
hdyc-svelte/src/lib/utils/formatConversionValue.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function formatConversionValue(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
const rounded = parseFloat(value.toFixed(6));
|
||||
if (rounded !== 0) {
|
||||
return rounded.toString();
|
||||
}
|
||||
const precise = value.toFixed(12).replace(/\.?0+$/, '');
|
||||
if (precise !== '0' && precise !== '') {
|
||||
return precise;
|
||||
}
|
||||
|
||||
// Fallback for extremely small values: use scientific notation but clean it up
|
||||
const scientific = value.toExponential();
|
||||
return scientific.replace(/\.?0+e/, 'e');
|
||||
}
|
||||
18
hdyc-svelte/src/lib/utils/formatScientific.ts
Normal file
18
hdyc-svelte/src/lib/utils/formatScientific.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ScientificNotationParts {
|
||||
base: string;
|
||||
exponent: string;
|
||||
}
|
||||
|
||||
const SCIENTIFIC_REGEX = /^([+-]?\d+(?:\.\d+)?)(?:[eE]([+-]?\d+))$/;
|
||||
|
||||
export function parseScientificNotation(value: string): ScientificNotationParts | null {
|
||||
const match = SCIENTIFIC_REGEX.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const exponent = match[2].replace(/^\+/, '') || '0';
|
||||
return {
|
||||
base: match[1],
|
||||
exponent,
|
||||
};
|
||||
}
|
||||
@@ -7,251 +7,8 @@
|
||||
import '../app.css';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import { palettes, type ThemeMode, type Palette } from '$lib/palettes';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
type PaletteVar =
|
||||
| 'bg'
|
||||
| 'bg-elevated'
|
||||
| 'sidebar-bg'
|
||||
| 'card-bg'
|
||||
| 'input-bg'
|
||||
| 'hover-bg'
|
||||
| 'border'
|
||||
| 'text'
|
||||
| 'text-muted'
|
||||
| 'accent'
|
||||
| 'accent-dark'
|
||||
| 'accent-glow'
|
||||
| 'accent-gradient'
|
||||
| 'header-bg';
|
||||
|
||||
type PaletteTheme = Record<PaletteVar, string>;
|
||||
|
||||
type Palette = {
|
||||
slug: string;
|
||||
label: string;
|
||||
light: PaletteTheme;
|
||||
dark: PaletteTheme;
|
||||
};
|
||||
|
||||
const palettes: Palette[] = [
|
||||
{
|
||||
slug: 'classic',
|
||||
label: 'Classic',
|
||||
light: {
|
||||
bg: '#f8fafc',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#ffffff',
|
||||
'input-bg': 'rgba(15, 23, 42, 0.04)',
|
||||
'hover-bg': 'rgba(15, 23, 42, 0.08)',
|
||||
border: 'rgba(15, 23, 42, 0.12)',
|
||||
text: '#0f172a',
|
||||
'text-muted': '#475569',
|
||||
accent: '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.15)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
bg: '#0c0f14',
|
||||
'bg-elevated': '#12161e',
|
||||
'sidebar-bg': '#10141b',
|
||||
'card-bg': 'rgba(18, 22, 30, 0.85)',
|
||||
'input-bg': 'rgba(255, 255, 255, 0.04)',
|
||||
'hover-bg': 'rgba(255, 255, 255, 0.06)',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
text: '#e8ecf4',
|
||||
'text-muted': '#7b8498',
|
||||
accent: '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.15)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
|
||||
'header-bg': 'rgba(12, 15, 20, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'emerald',
|
||||
label: 'Emerald',
|
||||
light: {
|
||||
'bg': '#f6fbf9',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#ffffff',
|
||||
'input-bg': '#ecf7f1',
|
||||
'hover-bg': '#d5f0df',
|
||||
'border': 'rgba(4, 120, 87, 0.25)',
|
||||
'text': '#0b2c1f',
|
||||
'text-muted': '#4a6b5c',
|
||||
'accent': '#047857',
|
||||
'accent-dark': '#065f46',
|
||||
'accent-glow': 'rgba(4, 120, 87, 0.2)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #047857, #0ea5e9)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0b1313',
|
||||
'bg-elevated': 'rgba(4, 20, 15, 0.85)',
|
||||
'sidebar-bg': '#08110f',
|
||||
'card-bg': 'rgba(6, 19, 13, 0.75)',
|
||||
'input-bg': 'rgba(16, 185, 129, 0.08)',
|
||||
'hover-bg': 'rgba(16, 185, 129, 0.12)',
|
||||
'border': 'rgba(16, 185, 129, 0.35)',
|
||||
'text': '#e9fcea',
|
||||
'text-muted': '#9fdac4',
|
||||
'accent': '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #0ea5e9)',
|
||||
'header-bg': 'rgba(12, 15, 20, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'sunset',
|
||||
label: 'Sunset',
|
||||
light: {
|
||||
'bg': '#fff8f2',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fff4ef',
|
||||
'input-bg': '#ffe3d8',
|
||||
'hover-bg': '#ffd3bf',
|
||||
'border': 'rgba(249, 115, 22, 0.25)',
|
||||
'text': '#3d1b0b',
|
||||
'text-muted': '#7a4a37',
|
||||
'accent': '#f97316',
|
||||
'accent-dark': '#c2410c',
|
||||
'accent-glow': 'rgba(249, 115, 22, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #f97316, #ec4899)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.96)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0f0505',
|
||||
'bg-elevated': 'rgba(15, 5, 5, 0.85)',
|
||||
'sidebar-bg': '#0c0404',
|
||||
'card-bg': 'rgba(19, 6, 6, 0.7)',
|
||||
'input-bg': 'rgba(251, 113, 133, 0.08)',
|
||||
'hover-bg': 'rgba(251, 113, 133, 0.14)',
|
||||
'border': 'rgba(251, 113, 133, 0.35)',
|
||||
'text': '#ffe7e0',
|
||||
'text-muted': '#f9a6aa',
|
||||
'accent': '#fb7185',
|
||||
'accent-dark': '#be123c',
|
||||
'accent-glow': 'rgba(251, 113, 133, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fb7185, #f97316)',
|
||||
'header-bg': 'rgba(12, 8, 6, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'ocean',
|
||||
label: 'Ocean',
|
||||
light: {
|
||||
'bg': '#f4fbff',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#f0f7ff',
|
||||
'input-bg': '#dcefff',
|
||||
'hover-bg': '#cae8ff',
|
||||
'border': 'rgba(14, 165, 233, 0.25)',
|
||||
'text': '#06274e',
|
||||
'text-muted': '#4d6993',
|
||||
'accent': '#0ea5e9',
|
||||
'accent-dark': '#0369a1',
|
||||
'accent-glow': 'rgba(14, 165, 233, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #0ea5e9, #4753ff)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#030b12',
|
||||
'bg-elevated': 'rgba(2, 9, 20, 0.85)',
|
||||
'sidebar-bg': '#050c16',
|
||||
'card-bg': 'rgba(3, 13, 26, 0.75)',
|
||||
'input-bg': 'rgba(14, 165, 233, 0.08)',
|
||||
'hover-bg': 'rgba(14, 165, 233, 0.15)',
|
||||
'border': 'rgba(14, 165, 233, 0.4)',
|
||||
'text': '#e6f6ff',
|
||||
'text-muted': '#a1c4e8',
|
||||
'accent': '#38bdf8',
|
||||
'accent-dark': '#0369a1',
|
||||
'accent-glow': 'rgba(14, 165, 233, 0.35)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #38bdf8, #0f172a)',
|
||||
'header-bg': 'rgba(6, 15, 30, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'orchid',
|
||||
label: 'Orchid',
|
||||
light: {
|
||||
'bg': '#fdf6ff',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fdf2ff',
|
||||
'input-bg': '#f5e4ff',
|
||||
'hover-bg': '#e9d4ff',
|
||||
'border': 'rgba(168, 85, 247, 0.25)',
|
||||
'text': '#2c0a3a',
|
||||
'text-muted': '#6a5277',
|
||||
'accent': '#a855f7',
|
||||
'accent-dark': '#6d28d9',
|
||||
'accent-glow': 'rgba(168, 85, 247, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #c084fc, #a855f7)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.97)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0c0215',
|
||||
'bg-elevated': 'rgba(10, 3, 30, 0.85)',
|
||||
'sidebar-bg': '#090118',
|
||||
'card-bg': 'rgba(12, 2, 25, 0.75)',
|
||||
'input-bg': 'rgba(168, 85, 247, 0.08)',
|
||||
'hover-bg': 'rgba(168, 85, 247, 0.16)',
|
||||
'border': 'rgba(168, 85, 247, 0.35)',
|
||||
'text': '#f5e6ff',
|
||||
'text-muted': '#c5a3e8',
|
||||
'accent': '#d946ef',
|
||||
'accent-dark': '#831843',
|
||||
'accent-glow': 'rgba(217, 70, 239, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #d946ef, #fb7185)',
|
||||
'header-bg': 'rgba(13, 6, 23, 0.95)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'citrus',
|
||||
label: 'Citrus',
|
||||
light: {
|
||||
'bg': '#fffdf5',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fffaf0',
|
||||
'input-bg': '#fff4d8',
|
||||
'hover-bg': '#ffeec1',
|
||||
'border': 'rgba(250, 204, 21, 0.25)',
|
||||
'text': '#1f1505',
|
||||
'text-muted': '#5b4a1e',
|
||||
'accent': '#fbbf24',
|
||||
'accent-dark': '#c2410c',
|
||||
'accent-glow': 'rgba(250, 204, 21, 0.3)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #d97706)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.98)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#1a1203',
|
||||
'bg-elevated': 'rgba(26, 18, 3, 0.9)',
|
||||
'sidebar-bg': '#130e02',
|
||||
'card-bg': 'rgba(26, 18, 3, 0.75)',
|
||||
'input-bg': 'rgba(250, 204, 21, 0.08)',
|
||||
'hover-bg': 'rgba(250, 204, 21, 0.14)',
|
||||
'border': 'rgba(250, 204, 21, 0.35)',
|
||||
'text': '#fff8e7',
|
||||
'text-muted': '#f6dea1',
|
||||
'accent': '#fbbf24',
|
||||
'accent-dark': '#b45309',
|
||||
'accent-glow': 'rgba(250, 204, 21, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #f97316)',
|
||||
'header-bg': 'rgba(15, 9, 2, 0.9)',
|
||||
},
|
||||
},
|
||||
];
|
||||
const matomoContainerSrc = 'https://matomo.howdoyouconvert.com/js/container_B3r877Kn.js';
|
||||
|
||||
type WindowWithAnalytics = Window & {
|
||||
@@ -265,6 +22,7 @@
|
||||
let isMobileHeader = false;
|
||||
let theme: ThemeMode = 'dark';
|
||||
let selectedPaletteIndex = 0;
|
||||
let savedScrollRestoration: ScrollRestoration | null = null;
|
||||
$: isHomepage = $page.url.pathname === '/';
|
||||
$: if (isHomepage && (sidebarOpen || headerSearchOpen)) {
|
||||
sidebarOpen = false;
|
||||
@@ -326,14 +84,30 @@
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
afterNavigate(() => {
|
||||
const scrollToTop = () => {
|
||||
if (!browser) return;
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to, type }) => {
|
||||
sidebarOpen = false;
|
||||
headerSearchOpen = false;
|
||||
|
||||
if (!browser) return;
|
||||
if (type === 'popstate') return;
|
||||
if (!from || !to) return;
|
||||
if (from.url.pathname === to.url.pathname) return;
|
||||
|
||||
scrollToTop();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
const appWindow = window as WindowWithAnalytics;
|
||||
if ('scrollRestoration' in window.history) {
|
||||
savedScrollRestoration = window.history.scrollRestoration;
|
||||
window.history.scrollRestoration = 'manual';
|
||||
}
|
||||
let idleCallbackId: number | null = null;
|
||||
let fallbackTimeoutId: number | null = null;
|
||||
|
||||
@@ -411,6 +185,9 @@
|
||||
window.clearTimeout(fallbackTimeoutId);
|
||||
}
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
if (savedScrollRestoration !== null) {
|
||||
window.history.scrollRestoration = savedScrollRestoration;
|
||||
}
|
||||
};
|
||||
|
||||
if ('addEventListener' in mediaQuery) {
|
||||
@@ -461,7 +238,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<a href="/" class="site-logo">
|
||||
<span>How Do You</span><span class="logo-accent">Convert</span><span class="logo-domain">.com</span>
|
||||
<span>How</span><span>Do</span><span>You</span><span class="logo-accent">Convert</span><span class="logo-domain">.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { categories, calculators } from '$lib/data/calculators';
|
||||
import { categories, totalCalculators } from '$lib/data/stats';
|
||||
import CategoryCard from '$lib/components/CategoryCard.svelte';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import { buildSeoMeta, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
@@ -18,8 +18,8 @@
|
||||
key,
|
||||
...meta,
|
||||
}));
|
||||
const totalCalculators = calculators.length;
|
||||
const totalCategories = Object.keys(homepageCategories).length;
|
||||
const totalConversions = totalCalculators;
|
||||
const totalCategoriesCount = Object.keys(homepageCategories).length;
|
||||
const pageTitle = `${SITE_NAME} — Free Unit Conversion Calculators`;
|
||||
const pageDescription = 'Convert between hundreds of units instantly. Free online calculators for length, weight, temperature, volume, area, speed, energy, power, data and more.';
|
||||
const seo = buildSeoMeta({
|
||||
@@ -49,12 +49,12 @@
|
||||
<meta name="twitter:card" content={seo.twitter.card} />
|
||||
<meta name="twitter:title" content={seo.twitter.title} />
|
||||
<meta name="twitter:description" content={seo.twitter.description} />
|
||||
<script type="application/ld+json">{websiteJsonLd}</script>
|
||||
{@html `<script type="application/ld+json">${websiteJsonLd}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<section class="hero">
|
||||
<h1>How Do You Convert?</h1>
|
||||
<p>Fast, bidirectional unit conversions with no ads.</p>
|
||||
<p>Fast unit conversions with no ads.</p>
|
||||
<div class="search-center">
|
||||
<SearchBar idPrefix="home-search" />
|
||||
</div>
|
||||
@@ -62,11 +62,11 @@
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<div class="stat-num">{totalCalculators}</div>
|
||||
<div class="stat-num">{totalConversions}</div>
|
||||
<div class="stat-label">Converters</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num">{totalCategories}</div>
|
||||
<div class="stat-num">{totalCategoriesCount}</div>
|
||||
<div class="stat-label">Categories</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
3
hdyc-svelte/src/routes/+page.ts
Normal file
3
hdyc-svelte/src/routes/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Prerender the homepage as static HTML at build time.
|
||||
// adapter-node will serve this as a static file — no SSR round-trip.
|
||||
export const prerender = true;
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Calculator from '$lib/components/Calculator.svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
|
||||
@@ -11,7 +8,9 @@
|
||||
$: calc = data.calculator;
|
||||
$: related = data.related;
|
||||
$: pageTitle = `${calc.name} — ${SITE_NAME}`;
|
||||
$: pageDescription = `Convert ${calc.labels.in1} to ${calc.labels.in2} instantly with our free online calculator. Accurate bidirectional conversion with the exact formula shown.`;
|
||||
$: pageDescription = ['3col', '3col-mul'].includes(calc.type)
|
||||
? `Compute ${calc.labels.in3 ?? 'the derived value'} using ${calc.labels.in1} and ${calc.labels.in2}. Enter any two fields to solve the third.`
|
||||
: `Convert ${calc.labels.in1} to ${calc.labels.in2} instantly with our free online calculator. Accurate two-way conversion with the exact formula shown.`;
|
||||
$: seo = buildSeoMeta({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
@@ -54,15 +53,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (!browser) return;
|
||||
window.scrollTo({ top: 0 });
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
window.scrollTo({ top: 0 });
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -78,8 +68,8 @@
|
||||
<meta name="twitter:card" content={seo.twitter.card} />
|
||||
<meta name="twitter:title" content={seo.twitter.title} />
|
||||
<meta name="twitter:description" content={seo.twitter.description} />
|
||||
<script type="application/ld+json">{breadcrumbJsonLd}</script>
|
||||
<script type="application/ld+json">{webPageJsonLd}</script>
|
||||
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
|
||||
{@html `<script type="application/ld+json">${webPageJsonLd}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
@@ -92,7 +82,9 @@
|
||||
|
||||
<h1 class="page-title calculator-page-title">{calc.name}</h1>
|
||||
|
||||
<Calculator config={calc} showTitle={false} />
|
||||
{#key calc.slug}
|
||||
<Calculator config={calc} showTitle={false} />
|
||||
{/key}
|
||||
|
||||
<div class="seo-content">
|
||||
{#if calc.descriptionHTML}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
import { getConversionRateText } from '$lib/utils/conversionRate';
|
||||
|
||||
const handleCalcTooltipMousemove = (event: MouseEvent) => {
|
||||
const card = event.currentTarget as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = Math.min(Math.max(event.clientX - rect.left, 0), rect.width);
|
||||
const y = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
|
||||
card.style.setProperty('--calc-tooltip-left', `${x}px`);
|
||||
card.style.setProperty('--calc-tooltip-top', `${y}px`);
|
||||
card.style.setProperty('--calc-tooltip-bottom', 'auto');
|
||||
card.style.setProperty('--calc-tooltip-translate', 'calc(-100% - 0.55rem)');
|
||||
};
|
||||
|
||||
const resetCalcTooltipPosition = (event: MouseEvent) => {
|
||||
const card = event.currentTarget as HTMLElement | null;
|
||||
if (!card) return;
|
||||
card.style.removeProperty('--calc-tooltip-left');
|
||||
card.style.removeProperty('--calc-tooltip-top');
|
||||
card.style.removeProperty('--calc-tooltip-bottom');
|
||||
card.style.removeProperty('--calc-tooltip-translate');
|
||||
};
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@@ -66,8 +88,8 @@
|
||||
<meta name="twitter:card" content={seo.twitter.card} />
|
||||
<meta name="twitter:title" content={seo.twitter.title} />
|
||||
<meta name="twitter:description" content={seo.twitter.description} />
|
||||
<script type="application/ld+json">{breadcrumbJsonLd}</script>
|
||||
<script type="application/ld+json">{collectionJsonLd}</script>
|
||||
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
|
||||
{@html `<script type="application/ld+json">${collectionJsonLd}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
@@ -85,8 +107,18 @@
|
||||
|
||||
<div class="calc-list">
|
||||
{#each data.calculators as calc}
|
||||
<a href="/{calc.slug}" class="calc-list-item">
|
||||
{@const conversionRateText = getConversionRateText(calc)}
|
||||
<a
|
||||
href="/{calc.slug}"
|
||||
class="calc-list-item"
|
||||
on:mousemove={handleCalcTooltipMousemove}
|
||||
on:mouseleave={resetCalcTooltipPosition}
|
||||
on:focus={resetCalcTooltipPosition}
|
||||
>
|
||||
{calc.name}
|
||||
{#if conversionRateText}
|
||||
<span class="calc-list-tooltip" role="tooltip">{conversionRateText}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { calculators, categories } from '$lib/data/calculators';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const calculatorUrls = calculators.map(
|
||||
(calc) => `
|
||||
<url>
|
||||
(calc) => ` <url>
|
||||
<loc>https://howdoyouconvert.com/${calc.slug}</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
@@ -12,8 +11,7 @@ export const GET: RequestHandler = async () => {
|
||||
);
|
||||
|
||||
const categoryUrls = Object.keys(categories).map(
|
||||
(category) => `
|
||||
<url>
|
||||
(category) => ` <url>
|
||||
<loc>https://howdoyouconvert.com/category/${category}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
@@ -27,8 +25,8 @@ export const GET: RequestHandler = async () => {
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
${categoryUrls.join('')}
|
||||
${calculatorUrls.join('')}
|
||||
${categoryUrls.join('\n')}
|
||||
${calculatorUrls.join('\n')}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(sitemap, {
|
||||
|
||||
37133
hdyc-svelte/static/data/calculators.json
Normal file
37133
hdyc-svelte/static/data/calculators.json
Normal file
File diff suppressed because it is too large
Load Diff
200
migrate.py
200
migrate.py
@@ -1,10 +1,13 @@
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
CALCLIST = BASE_DIR / 'calculators_list.md'
|
||||
OUTPUT_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/calculators.ts'
|
||||
STATS_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/stats.ts'
|
||||
CALCULATORS_JSON = BASE_DIR / 'hdyc-svelte/static/data/calculators.json'
|
||||
|
||||
CATEGORY_KEYS = [
|
||||
'length',
|
||||
@@ -29,8 +32,87 @@ CATEGORY_KEYS = [
|
||||
'other',
|
||||
]
|
||||
|
||||
CATEGORIES = {
|
||||
'length': {'label': 'Length / Distance', 'icon': '📏'},
|
||||
'weight': {'label': 'Weight / Mass', 'icon': '⚖️'},
|
||||
'temperature': {'label': 'Temperature', 'icon': '🌡️'},
|
||||
'volume': {'label': 'Volume', 'icon': '🧪'},
|
||||
'fluids': {'label': 'Fluids', 'icon': '💧'},
|
||||
'area': {'label': 'Area', 'icon': '🔳'},
|
||||
'speed': {'label': 'Speed / Velocity', 'icon': '💨'},
|
||||
'pressure': {'label': 'Pressure', 'icon': '🔽'},
|
||||
'energy': {'label': 'Energy', 'icon': '⚡'},
|
||||
'magnetism': {'label': 'Magnetism', 'icon': '🧲'},
|
||||
'power': {'label': 'Power', 'icon': '🔌'},
|
||||
'data': {'label': 'Data Storage', 'icon': '💾'},
|
||||
'time': {'label': 'Time', 'icon': '⏱️'},
|
||||
'angle': {'label': 'Angle', 'icon': '📐'},
|
||||
'number-systems': {'label': 'Number Systems', 'icon': '🔢'},
|
||||
'radiation': {'label': 'Radiation', 'icon': '☢️'},
|
||||
'electrical': {'label': 'Electrical', 'icon': '🔋'},
|
||||
'force': {'label': 'Force / Torque', 'icon': '💪'},
|
||||
'light': {'label': 'Light', 'icon': '💡'},
|
||||
'other': {'label': 'Other', 'icon': '🔄'},
|
||||
}
|
||||
|
||||
CATEGORY_SET = set(CATEGORY_KEYS)
|
||||
|
||||
# Lightweight label normalization to catch duplicate/identity conversions
|
||||
# that differ only by abbreviations (e.g., "cm" vs "centimeters").
|
||||
TOKEN_MAP = {
|
||||
'cm': 'centimeter',
|
||||
'centimeter': 'centimeter',
|
||||
'centimetre': 'centimeter',
|
||||
'centimetres': 'centimeter',
|
||||
'centimeters': 'centimeter',
|
||||
'mm': 'millimeter',
|
||||
'millimeter': 'millimeter',
|
||||
'millimeters': 'millimeter',
|
||||
'millimetre': 'millimeter',
|
||||
'millimetres': 'millimeter',
|
||||
'm': 'meter',
|
||||
'meter': 'meter',
|
||||
'meters': 'meter',
|
||||
'metre': 'meter',
|
||||
'metres': 'meter',
|
||||
'km': 'kilometer',
|
||||
'kilometer': 'kilometer',
|
||||
'kilometers': 'kilometer',
|
||||
'kilometre': 'kilometer',
|
||||
'kilometres': 'kilometer',
|
||||
'in': 'inch',
|
||||
'inch': 'inch',
|
||||
'inches': 'inch',
|
||||
'ft': 'foot',
|
||||
'foot': 'foot',
|
||||
'feet': 'foot',
|
||||
}
|
||||
|
||||
|
||||
def normalize_label(label: str) -> str:
|
||||
"""Canonicalize a unit label for duplicate detection.
|
||||
|
||||
- Lowercase
|
||||
- Replace '/' with ' per ' to align fraction style with text style
|
||||
- Strip punctuation into tokens
|
||||
- Collapse common abbreviations/plurals via TOKEN_MAP and simple singularization
|
||||
"""
|
||||
cleaned = label.lower().replace('/', ' per ')
|
||||
tokens = re.split(r'[^a-z0-9]+', cleaned)
|
||||
|
||||
normalized_tokens = []
|
||||
for tok in tokens:
|
||||
if not tok:
|
||||
continue
|
||||
base = tok
|
||||
# Drop a trailing 's' for simple plurals, but avoid short abbreviations like 'cms'
|
||||
if base.endswith('s') and len(base) > 3:
|
||||
base = base[:-1]
|
||||
base = TOKEN_MAP.get(base, base)
|
||||
normalized_tokens.append(base)
|
||||
|
||||
return ' '.join(normalized_tokens)
|
||||
|
||||
def load_external_descriptions():
|
||||
# Placeholder for future enrichment sources.
|
||||
return {}
|
||||
@@ -92,6 +174,7 @@ def process():
|
||||
calculators_ts_entries = []
|
||||
|
||||
seen_slugs = set()
|
||||
seen_norm_pairs = set()
|
||||
for raw_name, slug, category_raw, factor_raw in active_rows:
|
||||
if raw_name == 'Calculator Name' or not slug:
|
||||
continue
|
||||
@@ -104,7 +187,23 @@ def process():
|
||||
in1, in2 = parsed
|
||||
else:
|
||||
in1, in2 = "From", "To"
|
||||
|
||||
|
||||
custom_labels = None
|
||||
|
||||
norm_in1 = normalize_label(in1)
|
||||
norm_in2 = normalize_label(in2)
|
||||
|
||||
# Skip identity conversions that only differ by spelling/abbreviation
|
||||
if norm_in1 == norm_in2:
|
||||
print(f"Skipping identity converter {slug}: {in1} -> {in2}")
|
||||
continue
|
||||
|
||||
pair_key = (norm_in1, norm_in2)
|
||||
if pair_key in seen_norm_pairs:
|
||||
print(f"Skipping duplicate converter {slug}: {in1} -> {in2}")
|
||||
continue
|
||||
seen_norm_pairs.add(pair_key)
|
||||
|
||||
category = normalize_category(category_raw)
|
||||
if not category:
|
||||
raise ValueError(f'Category required for {display_name}')
|
||||
@@ -118,8 +217,26 @@ def process():
|
||||
c_type = 'standard'
|
||||
factor_val = "1"
|
||||
offset_val = "0"
|
||||
|
||||
if '1/x' in factor_raw:
|
||||
|
||||
# Special-case calculator families that require custom math beyond simple factors.
|
||||
if 'molarity-to-grams-per-liter' == slug:
|
||||
c_type = 'molarity'
|
||||
custom_labels = {'in1': 'Molarity (mol/L)', 'in2': 'Grams per liter', 'in3': 'Molar mass (g/mol)'}
|
||||
elif 'rockwell-c-to-vickers' == slug:
|
||||
c_type = 'rockwell-vickers'
|
||||
elif 'ev-to-lux' in slug or 'lux-to-ev' in slug:
|
||||
c_type = 'ev-lux'
|
||||
elif 'focal-length-to-angle-of-view' in slug:
|
||||
c_type = 'aov'
|
||||
elif 'awg' in slug:
|
||||
c_type = 'awg'
|
||||
elif 'swg-to' in slug or '-to-swg' in slug:
|
||||
c_type = 'swg'
|
||||
elif 'brinell-to-rockwell-c' == slug or 'rockwell-c-to-brinell' == slug:
|
||||
c_type = 'brinell-rockwell'
|
||||
elif 'saybolt-universal-seconds-to-centistokes' == slug:
|
||||
c_type = 'sus-cst'
|
||||
elif '1/x' in factor_raw:
|
||||
c_type = 'inverse'
|
||||
factor_val = "1"
|
||||
elif 'Multi-Variable' in factor_raw:
|
||||
@@ -167,6 +284,11 @@ def process():
|
||||
except:
|
||||
pass
|
||||
|
||||
# Give 3-col calculators honest display names instead of "A to B"
|
||||
if c_type in ['3col', '3col-mul'] and split_conversion_name(display_name):
|
||||
op = '*' if c_type == '3col-mul' else '/'
|
||||
display_name = f"{in1} {op} {in2}"
|
||||
|
||||
# Avoid escaping single quotes by using JSON or dict
|
||||
entry = {
|
||||
'slug': slug,
|
||||
@@ -180,11 +302,19 @@ def process():
|
||||
# Determine labels
|
||||
labels = {'in1': in1, 'in2': in2}
|
||||
if c_type in ['3col', '3col-mul']:
|
||||
# generic 3rd label
|
||||
if 'watts' in slug and 'amps' in slug: labels['in3'] = 'Volts'
|
||||
elif 'lumens' in slug: labels['in3'] = 'Area (sq m)'
|
||||
elif 'moles' in slug: labels['in3'] = 'Molar Mass'
|
||||
else: labels['in3'] = 'Result'
|
||||
# generic 3rd label; make it descriptive instead of the vague "Result"
|
||||
if 'watts' in slug and 'amps' in slug:
|
||||
labels['in3'] = 'Volts'
|
||||
elif 'lumens' in slug:
|
||||
labels['in3'] = 'Area (sq m)'
|
||||
elif 'moles' in slug:
|
||||
labels['in3'] = 'Molar Mass'
|
||||
else:
|
||||
op = '*' if c_type == '3col-mul' else '/'
|
||||
labels['in3'] = f"{in1} {op} {in2}"
|
||||
|
||||
if custom_labels:
|
||||
labels = custom_labels
|
||||
|
||||
entry['labels'] = labels
|
||||
|
||||
@@ -240,7 +370,7 @@ def process():
|
||||
def get_val(k):
|
||||
if k in units_10: return 10, units_10[k]
|
||||
if k in units_2: return 2, units_2[k]
|
||||
if k == 'bit': return 10, -1 # placeholder relative to bytes, though bits are 1/8 byte. Handling simple bytes here only
|
||||
if k == 'bit': return 2, -3 # bit is 1/8 of a byte (2^-3)
|
||||
return None, None
|
||||
|
||||
b1, e1 = get_val(in1_key)
|
||||
@@ -296,7 +426,7 @@ def process():
|
||||
# Ensure types are right
|
||||
# write to TS
|
||||
out = """// THIS FILE IS AUTO-GENERATED BY migrate.py
|
||||
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w';
|
||||
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w' | 'awg' | 'brinell-rockwell' | 'ev-lux' | 'aov' | 'swg' | 'rockwell-vickers' | 'sus-cst' | 'molarity';
|
||||
|
||||
export interface CalculatorDef {
|
||||
slug: string;
|
||||
@@ -314,28 +444,12 @@ export interface CalculatorDef {
|
||||
}
|
||||
|
||||
export const categories: Record<string, { label: string; icon: string }> = {
|
||||
length: { label: 'Length / Distance', icon: '📏' },
|
||||
weight: { label: 'Weight / Mass', icon: '⚖️' },
|
||||
temperature: { label: 'Temperature', icon: '🌡️' },
|
||||
volume: { label: 'Volume', icon: '🧪' },
|
||||
fluids: { label: 'Fluids', icon: '💧' },
|
||||
area: { label: 'Area', icon: '📐' },
|
||||
speed: { label: 'Speed / Velocity', icon: '💨' },
|
||||
pressure: { label: 'Pressure', icon: '🔽' },
|
||||
energy: { label: 'Energy', icon: '⚡' },
|
||||
magnetism: { label: 'Magnetism', icon: '🧲' },
|
||||
power: { label: 'Power', icon: '🔌' },
|
||||
data: { label: 'Data Storage', icon: '💾' },
|
||||
time: { label: 'Time', icon: '⏱️' },
|
||||
angle: { label: 'Angle', icon: '📐' },
|
||||
'number-systems':{ label: 'Number Systems', icon: '🔢' },
|
||||
radiation: { label: 'Radiation', icon: '☢️' },
|
||||
electrical: { label: 'Electrical', icon: '🔋' },
|
||||
force: { label: 'Force / Torque', icon: '💪' },
|
||||
light: { label: 'Light', icon: '💡' },
|
||||
other: { label: 'Other', icon: '🔄' },
|
||||
};
|
||||
"""
|
||||
for k, v in CATEGORIES.items():
|
||||
out += f" '{k}': {json.dumps(v, ensure_ascii=False).replace('{', '{ ').replace('}', ' }')},\n"
|
||||
out += "};\n"
|
||||
|
||||
out += """
|
||||
export const calculators: CalculatorDef[] = [
|
||||
"""
|
||||
for e in calculators_ts_entries:
|
||||
@@ -350,8 +464,13 @@ export const calculators: CalculatorDef[] = [
|
||||
out += """
|
||||
];
|
||||
|
||||
const slugIndex = new Map(calculators.map(c => [c.slug, c]));
|
||||
const slugIndex: Map<string, CalculatorDef> = new Map(
|
||||
calculators.map(calc => [calc.slug, calc])
|
||||
);
|
||||
|
||||
"""
|
||||
|
||||
out += """
|
||||
export function getCalculatorBySlug(slug: string): CalculatorDef | undefined {
|
||||
return slugIndex.get(slug);
|
||||
}
|
||||
@@ -383,5 +502,22 @@ export function searchCalculators(query: string): CalculatorDef[] {
|
||||
|
||||
print(f"Generated {len(calculators_ts_entries)} calculators into calculators.ts")
|
||||
|
||||
# Generate stats.ts
|
||||
total_count = len(calculators_ts_entries)
|
||||
stats_content = f"""// THIS FILE IS AUTO-GENERATED BY migrate.py
|
||||
export const categories: Record<string, {{ label: string; icon: string }}> = {json.dumps(CATEGORIES, indent=2, ensure_ascii=False)};
|
||||
|
||||
export const totalCalculators = {total_count};
|
||||
"""
|
||||
with open(STATS_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(stats_content)
|
||||
print(f"Generated stats.ts with {total_count} total calculators")
|
||||
|
||||
# Generate calculators.json for true lazy loading
|
||||
os.makedirs(os.path.dirname(CALCULATORS_JSON), exist_ok=True)
|
||||
with open(CALCULATORS_JSON, 'w', encoding='utf-8') as f:
|
||||
json.dump(calculators_ts_entries, f, ensure_ascii=False, indent=2)
|
||||
print(f"Generated calculators.json (Size: {os.path.getsize(CALCULATORS_JSON) // 1024}KB)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
process()
|
||||
|
||||
27
tests/test_apothecary_page.py
Normal file
27
tests/test_apothecary_page.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import unittest
|
||||
|
||||
URL = 'https://howdoyouconvert.com/apothecary-ounces-to-amu'
|
||||
|
||||
|
||||
class ApothecaryPageTests(unittest.TestCase):
|
||||
def test_apothecary_page_returns_success(self) -> None:
|
||||
"""The published URL should return a 200 so the calculator page stays healthy."""
|
||||
request = urllib.request.Request(
|
||||
URL,
|
||||
headers={
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
status = response.getcode()
|
||||
except urllib.error.HTTPError as exc:
|
||||
self.fail(f'{URL} returned HTTP {exc.code} ({exc.reason})')
|
||||
except urllib.error.URLError as exc:
|
||||
self.fail(f'{URL} could not be fetched: {exc}')
|
||||
|
||||
self.assertEqual(status, 200, f'{URL} returned {status}')
|
||||
106
tests/test_consistency.py
Normal file
106
tests/test_consistency.py
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import math
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||
|
||||
def _js_fmt(n: float) -> str:
|
||||
"""Mimics the fmt() function in engine.ts"""
|
||||
if not math.isfinite(n):
|
||||
return str(n)
|
||||
if n == 0:
|
||||
return "0"
|
||||
if abs(n) < 1e-6:
|
||||
return f"{n:.6e}".replace("e-0", "e-").replace("e+0", "e+")
|
||||
|
||||
# engine.ts uses parseFloat(n.toFixed(6)).toString()
|
||||
rounded = round(n, 6)
|
||||
if rounded == 0: # Handle -0.0
|
||||
return "0"
|
||||
if rounded == int(rounded):
|
||||
return str(int(rounded))
|
||||
return str(rounded)
|
||||
|
||||
def _js_fmt_precise(n: float) -> str:
|
||||
"""Mimics formatExampleValue in QuickConversionExample.svelte"""
|
||||
if n is None or math.isnan(n):
|
||||
return "—"
|
||||
if not math.isfinite(n):
|
||||
return str(n)
|
||||
if n == 0:
|
||||
return "0"
|
||||
|
||||
rounded = round(n, 6)
|
||||
if rounded != 0:
|
||||
if rounded == int(rounded):
|
||||
return str(int(rounded))
|
||||
return str(rounded)
|
||||
|
||||
# Precise version for very small numbers
|
||||
precise = f"{n:.12f}".rstrip('0').rstrip('.')
|
||||
return precise if precise else "0"
|
||||
|
||||
class TestCalculatorsConsistency(unittest.TestCase):
|
||||
def test_standard_calculators_consistency(self):
|
||||
text = CALCULATORS_TS.read_text(encoding="utf-8")
|
||||
|
||||
# Extract the calculators array content
|
||||
match = re.search(r"export const calculators: CalculatorDef\[\] = \[(.*?)\];", text, re.S)
|
||||
self.assertTrue(match, "Could not find calculators array in calculators.ts")
|
||||
|
||||
body = match.group(1)
|
||||
# Split by '{"slug":' to avoid splitting on nested braces
|
||||
raw_entries = body.split('{"slug":')
|
||||
|
||||
errors = []
|
||||
for raw_entry in raw_entries:
|
||||
if not raw_entry.strip():
|
||||
continue
|
||||
entry = '{"slug":' + raw_entry
|
||||
slug_match = re.search(r'"slug": "(.*?)"', entry)
|
||||
if not slug_match:
|
||||
continue
|
||||
slug = slug_match.group(1)
|
||||
|
||||
type_match = re.search(r'"type": "(.*?)"', entry)
|
||||
if not type_match or type_match.group(1) != "standard":
|
||||
continue
|
||||
|
||||
# Use non-greedy search for factor/offset and handle potential whitespace
|
||||
factor_match = re.search(r'"factor":\s*([0-9.eE+-]+)', entry)
|
||||
offset_match = re.search(r'"offset":\s*([0-9.eE+-]+)', entry)
|
||||
|
||||
factor = float(factor_match.group(1)) if factor_match else 1.0
|
||||
offset = float(offset_match.group(1)) if offset_match else 0.0
|
||||
|
||||
# 1. Formula Hint vs Chart Row 1 Consistency
|
||||
# Logic: solve(config, 1, "1") -> val2 = fmt(1 * factor + offset)
|
||||
row_one_output = _js_fmt(1.0 * factor + offset)
|
||||
|
||||
# 2. How to convert Examples Consistency (Inverse)
|
||||
if factor != 0:
|
||||
reverse_val = (1.0 - offset) / factor
|
||||
formatted_reverse = _js_fmt_precise(reverse_val)
|
||||
|
||||
# Specific check for Réaumur to Kelvin (User request)
|
||||
if slug == "reaumur-to-kelvin":
|
||||
if formatted_reverse != "-217.72":
|
||||
errors.append(f"[{slug}] Reverse example mismatch: expected -217.72, got {formatted_reverse}")
|
||||
if row_one_output != "274.4":
|
||||
errors.append(f"[{slug}] Chart row 1 mismatch: expected 274.4, got {row_one_output}")
|
||||
|
||||
# Specific check for Feet per minute to Knots (Previous bug fix)
|
||||
if slug == "feet-per-minute-to-knots":
|
||||
if row_one_output != "0.009875":
|
||||
errors.append(f"[{slug}] Chart row 1 mismatch: expected 0.009875, got {row_one_output}")
|
||||
if formatted_reverse != "101.268504":
|
||||
errors.append(f"[{slug}] Reverse example mismatch: expected 101.268504, got {formatted_reverse}")
|
||||
|
||||
if errors:
|
||||
self.fail("\n" + "\n".join(errors))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
46
tests/test_conversion_chart.py
Normal file
46
tests/test_conversion_chart.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import math
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||
NUMERIC_SAMPLES = [0.1, 0.5, 1, 2, 5, 10, 20, 50, 100]
|
||||
TARGET_SLUG = "angstroms-to-nautical-miles"
|
||||
|
||||
|
||||
def _parse_factor(slug: str) -> float:
|
||||
text = CALCULATORS_TS.read_text(encoding="utf-8")
|
||||
match = re.search(rf'"slug": "{slug}".*?"factor": ([0-9.eE+-]+)', text, flags=re.S)
|
||||
if not match:
|
||||
raise AssertionError(f"Could not find calculator definition for '{slug}'")
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def _js_fmt(value: float) -> str:
|
||||
if not math.isfinite(value):
|
||||
return str(value)
|
||||
if value == 0:
|
||||
return "0"
|
||||
abs_val = abs(value)
|
||||
if abs_val < 1e-6:
|
||||
return f"{value:.6e}"
|
||||
rounded = float(f"{value:.6f}")
|
||||
if rounded.is_integer():
|
||||
return str(int(rounded))
|
||||
return repr(rounded)
|
||||
|
||||
|
||||
class QuickConversionChartRoundingTests(unittest.TestCase):
|
||||
def test_angstroms_to_nautical_miles_chart_does_not_round_to_zero(self) -> None:
|
||||
factor = _parse_factor(TARGET_SLUG)
|
||||
outputs = [_js_fmt(sample * factor) for sample in NUMERIC_SAMPLES]
|
||||
self.assertTrue(
|
||||
any(output != "0" for output in outputs),
|
||||
f"{TARGET_SLUG} quick conversion chart rounded every sample down to 0: {outputs}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
27
tests/test_conversion_rate_tooltip.py
Normal file
27
tests/test_conversion_rate_tooltip.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONVERSION_RATE = (
|
||||
ROOT / "hdyc-svelte" / "src" / "lib" / "utils" / "conversionRate.ts"
|
||||
)
|
||||
|
||||
|
||||
class ConversionRateTooltipFormattingTests(unittest.TestCase):
|
||||
def test_conversion_rate_text_uses_formatter(self) -> None:
|
||||
text = CONVERSION_RATE.read_text(encoding="utf-8")
|
||||
normalized = " ".join(text.split())
|
||||
self.assertIn(
|
||||
"formatConversionValue(config.factor)",
|
||||
normalized,
|
||||
"Conversion rate helper must format the factor before inserting it into the tooltip text",
|
||||
)
|
||||
self.assertIn(
|
||||
"formatConversionValue(config.offset ?? 0)",
|
||||
normalized,
|
||||
"Conversion rate tooltip must format any offset before rendering",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
68
tests/test_gauss_to_oersted_conversion_rate.py
Normal file
68
tests/test_gauss_to_oersted_conversion_rate.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||
CONVERSION_RATE_UTIL = ROOT / "hdyc-svelte" / "src" / "lib" / "utils" / "conversionRate.ts"
|
||||
CATEGORY_PAGE = ROOT / "hdyc-svelte" / "src" / "routes" / "category" / "[category]" / "+page.svelte"
|
||||
CALCULATOR_COMPONENT = ROOT / "hdyc-svelte" / "src" / "lib" / "components" / "Calculator.svelte"
|
||||
TARGET_SLUG = "gauss-to-oersted"
|
||||
NON_APPLICABLE_TWO_INPUT_STANDARD_SLUGS = {
|
||||
"grams-per-liter-to-molarity",
|
||||
}
|
||||
|
||||
|
||||
def _extract_calculator_block(slug: str) -> str:
|
||||
for line in CALCULATORS_TS.read_text(encoding="utf-8").splitlines():
|
||||
if f'"slug": "{slug}"' in line:
|
||||
return line
|
||||
raise AssertionError(f"Could not find calculator definition for '{slug}'")
|
||||
|
||||
|
||||
class GaussToOerstedConversionRateRegressionTests(unittest.TestCase):
|
||||
def test_gauss_to_oersted_includes_factor_for_conversion_rate_and_tooltip(self) -> None:
|
||||
block = _extract_calculator_block(TARGET_SLUG)
|
||||
self.assertRegex(
|
||||
block,
|
||||
r'"factor":\s*[0-9.eE+-]+',
|
||||
"Missing factor on gauss-to-oersted prevents conversion rate text from rendering in both calculator footer and category tooltip.",
|
||||
)
|
||||
|
||||
def test_conversion_rate_is_wired_to_both_surfaces(self) -> None:
|
||||
util_text = CONVERSION_RATE_UTIL.read_text(encoding="utf-8")
|
||||
category_page_text = CATEGORY_PAGE.read_text(encoding="utf-8")
|
||||
calculator_component_text = CALCULATOR_COMPONENT.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("getConversionRateText", util_text)
|
||||
self.assertIn("conversionRateText = getConversionRateText(calc)", category_page_text)
|
||||
self.assertIn('role="tooltip"', category_page_text)
|
||||
self.assertIn("conversionRateText = getConversionRateText(config)", calculator_component_text)
|
||||
self.assertIn('<span class="formula-hint">', calculator_component_text)
|
||||
|
||||
def test_all_applicable_two_input_standard_calculators_have_factors(self) -> None:
|
||||
missing_factors: list[str] = []
|
||||
for line in CALCULATORS_TS.read_text(encoding="utf-8").splitlines():
|
||||
if '"type": "standard"' not in line:
|
||||
continue
|
||||
if '"labels": {"in1":' not in line or '"in2":' not in line or '"in3":' in line:
|
||||
continue
|
||||
slug_match = re.search(r'"slug": "([^"]+)"', line)
|
||||
if not slug_match:
|
||||
continue
|
||||
slug = slug_match.group(1)
|
||||
if slug in NON_APPLICABLE_TWO_INPUT_STANDARD_SLUGS:
|
||||
continue
|
||||
if '"factor":' not in line:
|
||||
missing_factors.append(slug)
|
||||
|
||||
self.assertEqual(
|
||||
missing_factors,
|
||||
[],
|
||||
f"Two-input standard calculators missing factors (and therefore conversion-rate text): {missing_factors}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -72,7 +72,7 @@ class HomepageCategoryRegressionTests(unittest.TestCase):
|
||||
def test_homepage_uses_canonical_categories_map(self) -> None:
|
||||
text = HOMEPAGE_SVELTE.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("import { categories, calculators } from '$lib/data/calculators';", text)
|
||||
self.assertIn("import { categories, totalCalculators } from '$lib/data/stats';", text)
|
||||
self.assertIn("requiredCategoryFallbacks", text)
|
||||
self.assertIn("fluids: { label: 'Fluids', icon: '💧' }", text)
|
||||
self.assertIn("magnetism: { label: 'Magnetism', icon: '🧲' }", text)
|
||||
|
||||
26
tests/test_quick_conversion_formatting.py
Normal file
26
tests/test_quick_conversion_formatting.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
QUICK_CONVERSION_EXAMPLE = (
|
||||
ROOT / "hdyc-svelte" / "src" / "lib" / "components" / "QuickConversionExample.svelte"
|
||||
)
|
||||
|
||||
|
||||
class QuickConversionExampleFormattingTests(unittest.TestCase):
|
||||
def test_formula_uses_formatted_factor_and_offset(self) -> None:
|
||||
text = QUICK_CONVERSION_EXAMPLE.read_text(encoding="utf-8")
|
||||
normalized = " ".join(text.split())
|
||||
snippet = (
|
||||
"1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} "
|
||||
"{config.labels.in2}"
|
||||
)
|
||||
self.assertIn(
|
||||
snippet,
|
||||
normalized,
|
||||
"The formula snippet should render formatted factor/offset values instead of raw floats",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user