Compare commits
141 Commits
45a06c7ad6
...
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 | ||
|
|
3b4e201444 | ||
|
|
9949d1248f | ||
|
|
2594f0e7ff | ||
|
|
a3a175c05c | ||
|
|
adb164c8e1 | ||
|
|
de10c47a8c | ||
|
|
055ecdebc3 | ||
|
|
49414b25cd | ||
|
|
856b752c0b | ||
|
|
c1aebbb5e2 | ||
|
|
5a8740722c | ||
|
|
146bfa8b48 | ||
|
|
a3e41653b2 | ||
|
|
85af8797df | ||
|
|
8ef9a0bb2f | ||
|
|
3d3e5c32c8 | ||
|
|
5ba1ecf459 | ||
|
|
f1afaa6d3a | ||
|
|
067d615b53 | ||
|
|
6ec90aef9a | ||
|
|
c9484bee7f | ||
|
|
d5c6b8d3c2 | ||
|
|
006ae4fa06 | ||
|
|
16ca847d55 | ||
|
|
82dd971200 | ||
|
|
3dd077b2ab | ||
|
|
102a47815f | ||
|
|
d6ea306ee0 | ||
|
|
fbab62f6f6 | ||
|
|
0586b35fe7 | ||
|
|
dc6bedfc47 | ||
|
|
ac196f2e47 |
10198
calculators_list.md
10198
calculators_list.md
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
|||||||
"start": "node build",
|
"start": "node build",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@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>
|
||||||
@@ -1,4 +1,66 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter/Inter-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: optional;
|
||||||
|
src: url('/fonts/inter/Inter-Medium.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: optional;
|
||||||
|
src: url('/fonts/inter/Inter-SemiBold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: optional;
|
||||||
|
src: url('/fonts/inter/Inter-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter/Inter-ExtraBold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: optional;
|
||||||
|
src: url('/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: optional;
|
||||||
|
src: url('/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: optional;
|
||||||
|
src: url('/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ─── Colors (Dark Theme) ─────────────────────────────── */
|
/* ─── Colors (Dark Theme) ─────────────────────────────── */
|
||||||
@@ -246,6 +308,28 @@ a {
|
|||||||
a:hover {
|
a:hover {
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
}
|
}
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
z-index: 500;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
transform: translateY(-160%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.skip-link:focus-visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Layout Shell ───────────────────────────────────────── */
|
/* ─── Layout Shell ───────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -259,37 +343,89 @@ a:hover {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
.site-header {
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border-bottom: 1px solid var(--border);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-logo {
|
.site-logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.site-logo .logo-accent {
|
.site-logo .logo-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.site-logo .logo-domain {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-header-search {
|
||||||
|
width: min(420px, 42vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon-btn {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.header-icon-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.header-icon-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-search {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--header-bg);
|
||||||
|
}
|
||||||
|
.mobile-header-search[hidden] {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 44px;
|
||||||
height: 36px;
|
height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
@@ -328,6 +464,7 @@ a:hover {
|
|||||||
.palette-dots {
|
.palette-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.palette-dot {
|
.palette-dot {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
@@ -341,9 +478,11 @@ a:hover {
|
|||||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
.palette-dot:hover {
|
.palette-dot:hover {
|
||||||
transform: translateY(-1px);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.palette-dot.active,
|
.palette-dot.active,
|
||||||
.palette-dot:focus-visible {
|
.palette-dot:focus-visible {
|
||||||
@@ -353,7 +492,9 @@ a:hover {
|
|||||||
.palette-dot:focus-visible {
|
.palette-dot:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
.floating-palette-controls:not(:hover):not(:focus-within) .palette-dot:not(.active) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.floating-palette-controls {
|
.floating-palette-controls {
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -368,12 +509,25 @@ a:hover {
|
|||||||
|
|
||||||
.hamburger {
|
.hamburger {
|
||||||
display: none;
|
display: none;
|
||||||
background: none;
|
align-items: center;
|
||||||
border: none;
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 1.4rem;
|
font-size: 1.2rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
padding: 0;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.hamburger:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.hamburger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-body {
|
.site-body {
|
||||||
@@ -392,6 +546,9 @@ a:hover {
|
|||||||
padding: clamp(1.5rem, 2vw, 3rem);
|
padding: clamp(1.5rem, 2vw, 3rem);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.main-content:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -404,22 +561,35 @@ a:hover {
|
|||||||
/* ─── Page Utilities ─────────────────────────────────────── */
|
/* ─── Page Utilities ─────────────────────────────────────── */
|
||||||
|
|
||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
.breadcrumbs ol {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.breadcrumbs li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.breadcrumbs li + li::before {
|
||||||
|
content: '›';
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
.breadcrumbs a {
|
.breadcrumbs a {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.breadcrumbs a:hover {
|
.breadcrumbs a:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.breadcrumbs .sep {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
@@ -432,6 +602,10 @@ a:hover {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calculator-page-title {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -468,22 +642,44 @@ a:hover {
|
|||||||
}
|
}
|
||||||
.category-grid {
|
.category-grid {
|
||||||
width: min(1160px, 100%);
|
width: min(1160px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 220px));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: minmax(132px, auto);
|
||||||
gap: clamp(0.75rem, 1.3vw, 1.25rem);
|
gap: clamp(0.75rem, 1.3vw, 1.25rem);
|
||||||
justify-content: center;
|
}
|
||||||
justify-items: stretch;
|
.category-grid .category-card {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.category-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.category-grid {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Calculator List (category page) ────────────────────── */
|
/* ─── Calculator List (category page) ────────────────────── */
|
||||||
|
|
||||||
.calc-list {
|
.calc-list {
|
||||||
display: grid;
|
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;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.calc-list-item {
|
.calc-list-item {
|
||||||
display: block;
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
--calc-tooltip-left: 50%;
|
||||||
|
--calc-tooltip-translate: -0.35rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -499,6 +695,40 @@ a:hover {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
color: var(--accent);
|
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 ─────────────────────────────────── */
|
/* ─── Related Converters ─────────────────────────────────── */
|
||||||
@@ -523,6 +753,11 @@ a:hover {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.related-chip:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── SEO Content ────────────────────────────────────────── */
|
/* ─── SEO Content ────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -600,13 +835,61 @@ a:hover {
|
|||||||
|
|
||||||
/* ─── Responsive ─────────────────────────────────────────── */
|
/* ─── Responsive ─────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.hamburger {
|
.hamburger {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.site-body {
|
||||||
|
gap: 1rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.site-header {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.header-left,
|
||||||
|
.header-right {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.site-logo {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.desktop-header-search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.site-header {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.site-logo {
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.desktop-header-search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.mobile-header-search {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
}
|
}
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
.breadcrumbs {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
@@ -615,15 +898,31 @@ a:hover {
|
|||||||
}
|
}
|
||||||
.category-section__inner {
|
.category-section__inner {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.category-section .section-heading {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.category-grid {
|
.category-grid {
|
||||||
width: min(960px, 100%);
|
width: 100%;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 200px));
|
max-width: 540px;
|
||||||
|
margin-inline: auto;
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
gap: clamp(0.7rem, 3vw, 1rem);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.category-grid .category-card {
|
||||||
|
flex: none;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
.stats-row {
|
.stats-row {
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
.seo-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
.site-body {
|
.site-body {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
|
|||||||
@@ -2,11 +2,46 @@
|
|||||||
<html lang="en" data-theme="dark" data-palette="classic">
|
<html lang="en" data-theme="dark" data-palette="classic">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/inter/Inter-ExtraBold.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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
|
||||||
const doc = document.documentElement;
|
const doc = document.documentElement;
|
||||||
|
try {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const savedPalette = localStorage.getItem('palette');
|
const savedPalette = localStorage.getItem('palette');
|
||||||
const prefersDark =
|
const prefersDark =
|
||||||
@@ -21,8 +56,9 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
%sveltekit.head%
|
<!-- SvelteKit head tags moved to top of <head> -->
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,18 +13,63 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
'.otf': 'font/otf'
|
'.otf': 'font/otf'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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([
|
||||||
|
'.js',
|
||||||
|
'.mjs',
|
||||||
|
'.css',
|
||||||
|
'.json',
|
||||||
|
'.svg',
|
||||||
|
'.png',
|
||||||
|
'.jpg',
|
||||||
|
'.jpeg',
|
||||||
|
'.webp',
|
||||||
|
'.avif',
|
||||||
|
'.ico',
|
||||||
|
'.woff2',
|
||||||
|
'.woff',
|
||||||
|
'.ttf',
|
||||||
|
'.otf'
|
||||||
|
]);
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const response = await resolve(event);
|
const response = await resolve(event);
|
||||||
if (event.url.pathname.startsWith('/_app/')) {
|
const pathname = event.url.pathname;
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
|
if (pathname.startsWith('/_app/')) {
|
||||||
const existing = response.headers.get('content-type');
|
const existing = response.headers.get('content-type');
|
||||||
const hasValidHeader = existing && existing.trim().length > 0;
|
const hasValidHeader = existing && existing.trim().length > 0;
|
||||||
if (!hasValidHeader) {
|
if (!hasValidHeader) {
|
||||||
const extension = path.extname(event.url.pathname).toLowerCase();
|
const extension = path.extname(pathname).toLowerCase();
|
||||||
const mime = extension && MIME_TYPES[extension];
|
const mime = extension && MIME_TYPES[extension];
|
||||||
if (mime) {
|
if (mime) {
|
||||||
response.headers.set('content-type', mime);
|
response.headers.set('content-type', mime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing hashed assets should never be cached; otherwise stale HTML can
|
||||||
|
// keep pointing to already-rotated files long after a deployment.
|
||||||
|
if (response.status >= 400) {
|
||||||
|
response.headers.set('cache-control', ASSET_404_CACHE_CONTROL);
|
||||||
|
} else if (pathname.startsWith('/_app/immutable/')) {
|
||||||
|
response.headers.set('cache-control', IMMUTABLE_ASSET_CACHE_CONTROL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(pathname).toLowerCase();
|
||||||
|
if (LONG_CACHE_EXTENSIONS.has(extension) && !contentType.includes('text/html')) {
|
||||||
|
response.headers.set('cache-control', IMMUTABLE_ASSET_CACHE_CONTROL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML documents should revalidate so they can reference the latest client
|
||||||
|
// bundle hashes after each deployment.
|
||||||
|
if (contentType.includes('text/html')) {
|
||||||
|
response.headers.set('cache-control', HTML_CACHE_CONTROL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1 +1,21 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Calculator favicon">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#115e59" />
|
||||||
|
<stop offset="1" stop-color="#0284c7" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#bg)" />
|
||||||
|
<rect x="17" y="8" width="30" height="48" rx="6" fill="#f8fafc" stroke="#0f172a" stroke-width="2" />
|
||||||
|
<rect x="22" y="14" width="20" height="9" rx="2" fill="#0f172a" />
|
||||||
|
<rect x="24" y="17" width="16" height="3" rx="1.5" fill="#67e8f9" />
|
||||||
|
<g fill="#0ea5e9">
|
||||||
|
<rect x="22" y="28" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="29" y="28" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="36" y="28" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="22" y="35" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="29" y="35" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="36" y="35" width="6" height="13" rx="1.5" fill="#f97316" />
|
||||||
|
<rect x="22" y="42" width="13" height="6" rx="1.5" fill="#14b8a6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -2,29 +2,52 @@
|
|||||||
import { solve } from '$lib/engine';
|
import { solve } from '$lib/engine';
|
||||||
import type { CalculatorDef } from '$lib/data/calculators';
|
import type { CalculatorDef } from '$lib/data/calculators';
|
||||||
import { page } from '$app/stores';
|
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 QuickDefinitionCard from '$lib/components/QuickDefinitionCard.svelte';
|
||||||
import QuickConversionExample from '$lib/components/QuickConversionExample.svelte';
|
import QuickConversionExample from '$lib/components/QuickConversionExample.svelte';
|
||||||
import QuickConversionTable from '$lib/components/QuickConversionTable.svelte';
|
import QuickConversionTable from '$lib/components/QuickConversionTable.svelte';
|
||||||
|
|
||||||
export let config: CalculatorDef;
|
export let config: CalculatorDef;
|
||||||
|
export let showTitle = true;
|
||||||
|
|
||||||
let val1 = '';
|
let val1 = '';
|
||||||
let val2 = '';
|
let val2 = '';
|
||||||
let val3 = '';
|
let val3 = '';
|
||||||
let activeField: 1 | 2 | 3 = 1;
|
let activeField: 1 | 2 | 3 = 1;
|
||||||
|
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;
|
$: 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);
|
$: isTextInput = ['base', 'text-bin', 'bin-text', 'dec-frac', 'dms-dd', 'dd-dms'].includes(config.type);
|
||||||
|
$: conversionRateText = getConversionRateText(config);
|
||||||
|
|
||||||
// Clear inputs on config (route) change
|
// Clear inputs only when navigating to a different calculator slug.
|
||||||
$: if (config) {
|
$: if (config?.slug) {
|
||||||
if (!paramsInitializing) clear();
|
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) {
|
||||||
function handleInput(source: 1 | 2 | 3) {
|
swapState = null;
|
||||||
|
}
|
||||||
activeField = source;
|
activeField = source;
|
||||||
const result = solve(config, source, val1, val2, val3);
|
const result = solve(config, source, val1, val2, val3);
|
||||||
if (source !== 1) val1 = result.val1;
|
if (source !== 1) val1 = result.val1;
|
||||||
@@ -33,32 +56,187 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function swap() {
|
function swap() {
|
||||||
[val1, val2] = [val2, val1];
|
if (!swapState) {
|
||||||
handleInput(1);
|
const manualField: 1 | 2 = activeField === 1 ? 1 : 2;
|
||||||
|
const manualValue = manualField === 1 ? val1 : val2;
|
||||||
|
const targetField: 1 | 2 = manualField === 1 ? 2 : 1;
|
||||||
|
swapState = { originalField: manualField, originalValue: manualValue };
|
||||||
|
if (targetField === 1) val1 = manualValue;
|
||||||
|
else val2 = manualValue;
|
||||||
|
handleInput(targetField, { preserveSwap: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualField = swapState.originalField;
|
||||||
|
const manualValue = swapState.originalValue;
|
||||||
|
if (manualField === 1) val1 = manualValue;
|
||||||
|
else val2 = manualValue;
|
||||||
|
swapState = null;
|
||||||
|
handleInput(manualField, { preserveSwap: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
val1 = '';
|
val1 = '';
|
||||||
val2 = '';
|
val2 = '';
|
||||||
val3 = '';
|
val3 = '';
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
const params = new URLSearchParams($page.url.search);
|
const params = new URLSearchParams($page.url.search);
|
||||||
if (params.has('v1')) { val1 = params.get('v1')!; handleInput(1); }
|
const hasV1 = params.has('v1');
|
||||||
else if (params.has('v2')) { val2 = params.get('v2')!; handleInput(2); }
|
const hasV2 = params.has('v2');
|
||||||
else if (params.has('v3') && has3) { val3 = params.get('v3')!; handleInput(3); }
|
const hasV3 = has3 && params.has('v3');
|
||||||
setTimeout(() => { paramsInitializing = false; }, 0);
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="calculator-card">
|
<div class="calculator-card">
|
||||||
|
{#if showTitle || config.teaser}
|
||||||
<div class="calc-header">
|
<div class="calc-header">
|
||||||
|
{#if showTitle}
|
||||||
<h2>{config.name}</h2>
|
<h2>{config.name}</h2>
|
||||||
|
{/if}
|
||||||
{#if config.teaser}
|
{#if config.teaser}
|
||||||
<p class="calc-subtitle">{config.teaser}</p>
|
<p class="calc-subtitle" class:no-title={!showTitle}>{config.teaser}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="calc-body" class:three-col={has3}>
|
<div class="calc-body" class:three-col={has3}>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -117,12 +295,54 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-footer">
|
<div class="calc-footer">
|
||||||
|
<div class="footer-controls" bind:this={footerControlsEl}>
|
||||||
<button type="button" class="clear-btn" on:click={clear} aria-label="Clear calculator inputs">
|
<button type="button" class="clear-btn" on:click={clear} aria-label="Clear calculator inputs">
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
{#if config.factor && config.type === 'standard'}
|
<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">
|
<span class="formula-hint">
|
||||||
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
|
{conversionRateText}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +384,9 @@
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
.calc-subtitle.no-title {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.calc-body {
|
.calc-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -244,6 +467,10 @@
|
|||||||
background: var(--accent-dark);
|
background: var(--accent-dark);
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
.swap-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
.calc-footer {
|
.calc-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -252,6 +479,13 @@
|
|||||||
padding: 1rem 2rem 1.25rem;
|
padding: 1rem 2rem 1.25rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.footer-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
padding: 0.5rem 1.25rem;
|
padding: 0.5rem 1.25rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -267,16 +501,97 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.clear-btn:focus-visible {
|
||||||
|
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 {
|
.formula-hint {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.calc-footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.formula-hint {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.calc-header {
|
||||||
|
padding: 1.2rem 1.2rem 0.9rem;
|
||||||
|
}
|
||||||
.calc-body {
|
.calc-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
.calc-body.three-col {
|
.calc-body.three-col {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -290,5 +605,8 @@
|
|||||||
.swap-btn:hover {
|
.swap-btn:hover {
|
||||||
transform: rotate(270deg);
|
transform: rotate(270deg);
|
||||||
}
|
}
|
||||||
|
.calc-footer {
|
||||||
|
padding: 0.9rem 1.25rem 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
min-height: 132px;
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -22,8 +24,6 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.category-card:hover {
|
.category-card:hover {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { solve } from '$lib/engine';
|
import { solve } from '$lib/engine';
|
||||||
import type { CalculatorDef } from '$lib/data/calculators';
|
import type { CalculatorDef } from '$lib/data/calculators';
|
||||||
|
import { formatConversionValue } from '$lib/utils/formatConversionValue';
|
||||||
|
|
||||||
export let config: CalculatorDef;
|
export let config: CalculatorDef;
|
||||||
|
|
||||||
@@ -17,40 +18,29 @@
|
|||||||
? solve(config, 1, exampleInput.toString(), '', '')
|
? solve(config, 1, exampleInput.toString(), '', '')
|
||||||
: null;
|
: null;
|
||||||
$: offset = config.offset ?? 0;
|
$: offset = config.offset ?? 0;
|
||||||
$: formulaExpression = supportsExample
|
$: hasOffset = Boolean(offset);
|
||||||
? `${exampleInput} × ${config.factor}${offset ? ` + ${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 =
|
$: reverseExampleValue =
|
||||||
supportsExample && config.factor !== 0
|
supportsExample && config.factor !== 0
|
||||||
? (1 - offset) / config.factor
|
? (1 - offset) / config.factor
|
||||||
: null;
|
: null;
|
||||||
$: formattedReverseValue = formatExampleValue(reverseExampleValue);
|
$: formattedReverseValue = formatConversionValue(reverseExampleValue);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if supportsExample && result}
|
{#if supportsExample && result}
|
||||||
<section class="example-card">
|
<section class="example-card">
|
||||||
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
|
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
|
||||||
<p class="example-note">
|
<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>
|
||||||
<p class="example-note">
|
<p class="example-note">
|
||||||
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
|
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
|
||||||
@@ -97,4 +87,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: 0.35rem;
|
margin-left: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.example-card {
|
||||||
|
margin: 0 1.25rem 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
|
|
||||||
type Row = { input: number; output: string };
|
type Row = { input: number; output: string };
|
||||||
|
|
||||||
const buildRow = (value: number): Row => {
|
const buildRow = (value: number, c: CalculatorDef): Row => {
|
||||||
const formatted = solve(config, 1, value.toString(), '', '');
|
const formatted = solve(c, 1, value.toString(), '', '');
|
||||||
return {
|
return {
|
||||||
input: value,
|
input: value,
|
||||||
output: formatted.val2 || '—'
|
output: formatted.val2 || '—',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
let outputLabel = 'target units';
|
let outputLabel = 'target units';
|
||||||
|
|
||||||
$: supportsTable = ['standard', 'inverse'].includes(config.type);
|
$: supportsTable = ['standard', 'inverse'].includes(config.type);
|
||||||
$: rows = supportsTable
|
$: rows = (config && supportsTable)
|
||||||
? numericSamples.map(buildRow)
|
? numericSamples.map(v => buildRow(v, config))
|
||||||
: [];
|
: [];
|
||||||
$: inputLabel = config.labels?.in1 ?? 'source units';
|
$: inputLabel = config.labels?.in1 ?? 'source units';
|
||||||
$: outputLabel = config.labels?.in2 ?? 'target units';
|
$: outputLabel = config.labels?.in2 ?? 'target units';
|
||||||
@@ -98,4 +98,17 @@
|
|||||||
.chart-output-unit {
|
.chart-output-unit {
|
||||||
font-variant: petite-caps;
|
font-variant: petite-caps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.quick-chart {
|
||||||
|
margin: 0.75rem 1.25rem 1.25rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
.chart-row {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.chart-statement {
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getDefinition } from '$lib/data/unitDefinitions';
|
import { getDefinition } from '$lib/data/unitDefinitions';
|
||||||
import type { CalculatorDef } from '$lib/data/calculators';
|
import type { CalculatorDef } from '$lib/data/calculatorLoader';
|
||||||
|
|
||||||
export let config: CalculatorDef;
|
export let config: CalculatorDef;
|
||||||
|
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
|
|
||||||
$: label1 = config.labels.in1 || 'Unit 1';
|
$: label1 = config.labels.in1 || 'Unit 1';
|
||||||
$: label2 = config.labels.in2 || 'Unit 2';
|
$: 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>
|
</script>
|
||||||
|
|
||||||
<section class="definition-card">
|
<section class="definition-card">
|
||||||
@@ -68,4 +70,14 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.definition-card {
|
||||||
|
margin: 0 1.25rem 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.definition-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { searchCalculators } from '$lib/data/calculators';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { loadCalculators, searchCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
|
||||||
|
|
||||||
|
export let idPrefix = 'search';
|
||||||
let query = '';
|
let query = '';
|
||||||
let focused = false;
|
let focused = false;
|
||||||
let selectedIndex = -1;
|
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;
|
||||||
|
$: activeDescendant = selectedIndex >= 0 && isOpen ? `${idPrefix}-option-${selectedIndex}` : undefined;
|
||||||
|
|
||||||
|
$: if (query !== lastQuery) {
|
||||||
|
selectedIndex = -1;
|
||||||
|
lastQuery = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (selectedIndex >= results.length) {
|
||||||
|
selectedIndex = results.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
@@ -33,12 +53,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="search-wrapper" class:active={focused && results.length > 0}>
|
<div class="search-wrapper" class:active={isOpen}>
|
||||||
<div class="search-input-wrap">
|
<div class="search-input-wrap">
|
||||||
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
on:focus={() => (focused = true)}
|
on:focus={() => (focused = true)}
|
||||||
@@ -46,6 +67,12 @@
|
|||||||
on:keydown={handleKeydown}
|
on:keydown={handleKeydown}
|
||||||
placeholder="Search conversions..."
|
placeholder="Search conversions..."
|
||||||
aria-label="Search conversions"
|
aria-label="Search conversions"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen ? 'true' : 'false'}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeDescendant}
|
||||||
/>
|
/>
|
||||||
{#if query}
|
{#if query}
|
||||||
<button
|
<button
|
||||||
@@ -61,11 +88,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if focused && results.length > 0}
|
{#if isOpen}
|
||||||
<ul class="results" role="listbox" aria-label="Conversion suggestions">
|
<ul class="results" id={listboxId} role="listbox" aria-label="Conversion suggestions">
|
||||||
{#each results as result, i}
|
{#each results as result, i}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
|
id={`${idPrefix}-option-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
class="result-item"
|
class="result-item"
|
||||||
class:selected={i === selectedIndex}
|
class:selected={i === selectedIndex}
|
||||||
@@ -162,6 +190,10 @@
|
|||||||
.result-item.selected {
|
.result-item.selected {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
.result-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
.result-name {
|
.result-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,118 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
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 expandedCategory = '';
|
||||||
let expandedUnits: Record<string, string> = {};
|
let expandedUnits: Record<string, string> = {};
|
||||||
|
let isDesktop = false;
|
||||||
|
let navBreakpoint: MediaQueryList | null = null;
|
||||||
|
let lastPath = '';
|
||||||
|
let lastDesktop = isDesktop;
|
||||||
|
let autoExpandedCategory = '';
|
||||||
|
|
||||||
|
const categoryPathRegex = /^\/category\/([^/]+)(?:\/|$)/;
|
||||||
|
|
||||||
|
function getCategorySlugFromPath(path: string) {
|
||||||
|
const match = path.match(categoryPathRegex);
|
||||||
|
return match?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
$: currentPath = $page.url.pathname;
|
$: currentPath = $page.url.pathname;
|
||||||
|
|
||||||
type UnitGroup = {
|
type UnitGroup = {
|
||||||
label: string;
|
label: string;
|
||||||
conversions: CalculatorDef[];
|
conversions: UnitConversionLink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UnitBucket = {
|
type UnitBucket = {
|
||||||
label: string;
|
label: string;
|
||||||
conversions: CalculatorDef[];
|
conversions: UnitConversionLink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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]) => {
|
$: categoryUnitGroups = Object.entries(categories).map(([key, meta]) => {
|
||||||
const buckets = new Map<string, UnitBucket>();
|
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 => {
|
calcs.forEach(calc => {
|
||||||
[calc.labels.in1, calc.labels.in2].forEach(unit => {
|
const pairKey = toPairKey(calc.labels.in1, calc.labels.in2);
|
||||||
const key = unit.toLowerCase();
|
const existing = canonicalByPair.get(pairKey);
|
||||||
const existing = buckets.get(key);
|
if (!existing || calc.slug.localeCompare(existing.slug) < 0) {
|
||||||
if (existing) {
|
canonicalByPair.set(pairKey, calc);
|
||||||
existing.conversions.push(calc);
|
|
||||||
} else {
|
|
||||||
buckets.set(key, {
|
|
||||||
label: unit,
|
|
||||||
conversions: [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()]
|
const units = [...buckets.entries()]
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([, bucket]) => ({
|
.map(([, bucket]) => ({
|
||||||
label: bucket.label,
|
label: bucket.label,
|
||||||
conversions: bucket.conversions.slice().sort((a, b) => a.name.localeCompare(b.name)),
|
conversions: sortConversionsForUnit(bucket.conversions),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { key, meta, units };
|
return { key, meta, units };
|
||||||
@@ -61,13 +133,80 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
navBreakpoint = window.matchMedia('(max-width: 1024px)');
|
||||||
|
const updateDesktop = (event?: MediaQueryListEvent) => {
|
||||||
|
const matches = event?.matches ?? navBreakpoint!.matches;
|
||||||
|
isDesktop = !matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDesktop();
|
||||||
|
|
||||||
|
const handleNavChange = (event: MediaQueryListEvent) => updateDesktop(event);
|
||||||
|
if ('addEventListener' in navBreakpoint) {
|
||||||
|
navBreakpoint.addEventListener('change', handleNavChange);
|
||||||
|
} else {
|
||||||
|
navBreakpoint.addListener(handleNavChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!navBreakpoint) return;
|
||||||
|
if ('removeEventListener' in navBreakpoint) {
|
||||||
|
navBreakpoint.removeEventListener('change', handleNavChange);
|
||||||
|
} else {
|
||||||
|
navBreakpoint.removeListener(handleNavChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (browser && (currentPath !== lastPath || isDesktop !== lastDesktop)) {
|
||||||
|
const slug = getCategorySlugFromPath(currentPath);
|
||||||
|
if (isDesktop && slug) {
|
||||||
|
expandedCategory = slug;
|
||||||
|
autoExpandedCategory = slug;
|
||||||
|
} else if (autoExpandedCategory && (!isDesktop || !slug)) {
|
||||||
|
if (expandedCategory === autoExpandedCategory) {
|
||||||
|
expandedCategory = '';
|
||||||
|
}
|
||||||
|
autoExpandedCategory = '';
|
||||||
|
}
|
||||||
|
lastPath = currentPath;
|
||||||
|
lastDesktop = isDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
export let open = false;
|
export let open = false;
|
||||||
|
|
||||||
|
$: if (browser && (isDesktop || open)) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: isSidebarHidden = !isDesktop && !open;
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && open && !isDesktop) {
|
||||||
|
closeSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar" class:open>
|
<svelte:window on:keydown={handleWindowKeydown} />
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="sidebar"
|
||||||
|
class:open={open}
|
||||||
|
id="site-navigation"
|
||||||
|
aria-hidden={isSidebarHidden ? 'true' : undefined}
|
||||||
|
inert={isSidebarHidden}
|
||||||
|
>
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h3>All Converters</h3>
|
<h3>All Converters</h3>
|
||||||
<button class="close-btn" on:click={() => (open = false)} aria-label="Close sidebar">✕</button>
|
<button type="button" class="close-btn" on:click={closeSidebar} aria-label="Close sidebar">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="Calculator categories">
|
<nav aria-label="Calculator categories">
|
||||||
{#each categoryUnitGroups as group}
|
{#each categoryUnitGroups as group}
|
||||||
@@ -100,14 +239,14 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if expandedUnits[group.key] === unit.label}
|
{#if expandedUnits[group.key] === unit.label}
|
||||||
<ul class="unit-list">
|
<ul class="unit-list">
|
||||||
{#each unit.conversions as calc}
|
{#each unit.conversions as conversion}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/{calc.slug}"
|
href="/{conversion.slug}"
|
||||||
class:current={currentPath === `/${calc.slug}`}
|
class:current={currentPath === `/${conversion.slug}`}
|
||||||
aria-current={currentPath === `/${calc.slug}` ? 'page' : undefined}
|
aria-current={currentPath === `/${conversion.slug}` ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{calc.name}
|
{conversion.name}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -131,10 +270,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#if open}
|
{#if open && !isDesktop}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<button type="button" class="overlay" aria-label="Close sidebar" on:click={closeSidebar}></button>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="overlay" on:click={() => (open = false)}></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -174,6 +311,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.close-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
@@ -196,6 +338,10 @@
|
|||||||
.cat-toggle:hover {
|
.cat-toggle:hover {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
.cat-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
.cat-toggle.active {
|
.cat-toggle.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -237,6 +383,11 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
.cat-list li a:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
.cat-list li a.current {
|
.cat-list li a.current {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -268,6 +419,10 @@
|
|||||||
.unit-toggle:hover {
|
.unit-toggle:hover {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
.unit-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
.unit-toggle.expanded {
|
.unit-toggle.expanded {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -293,23 +448,31 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
.unit-list li a:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
display: none;
|
display: none;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: -300px;
|
left: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
transition: left 0.3s ease;
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
will-change: transform;
|
||||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
left: 0;
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
.close-btn {
|
.close-btn {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
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 }> = {
|
const domainDefinitions: Record<string, { summary: string; context: string }> = {
|
||||||
length: {
|
length: {
|
||||||
@@ -17,6 +17,10 @@ const domainDefinitions: Record<string, { summary: string; context: string }> =
|
|||||||
summary: 'measures three-dimensional capacity inside containers or spaces.',
|
summary: 'measures three-dimensional capacity inside containers or spaces.',
|
||||||
context: 'Useful for describing liquids, gases, or any amount of space you can fill.',
|
context: 'Useful for describing liquids, gases, or any amount of space you can fill.',
|
||||||
},
|
},
|
||||||
|
fluids: {
|
||||||
|
summary: 'captures flow rates, flux densities, and transport-related metrics.',
|
||||||
|
context: 'Reach for these units when comparing pumps, pipes, or any movement of liquids, gases, or charged particles across an area.',
|
||||||
|
},
|
||||||
area: {
|
area: {
|
||||||
summary: 'tracks two-dimensional surface coverage.',
|
summary: 'tracks two-dimensional surface coverage.',
|
||||||
context: 'Helpful when sizing plots of land, floor space, or sheets of material.',
|
context: 'Helpful when sizing plots of land, floor space, or sheets of material.',
|
||||||
@@ -33,6 +37,10 @@ const domainDefinitions: Record<string, { summary: string; context: string }> =
|
|||||||
summary: 'represents the capacity to do work or release heat.',
|
summary: 'represents the capacity to do work or release heat.',
|
||||||
context: 'Energy units compare calories, joules, or BTUs when tracking work, heat, or stored energy.',
|
context: 'Energy units compare calories, joules, or BTUs when tracking work, heat, or stored energy.',
|
||||||
},
|
},
|
||||||
|
magnetism: {
|
||||||
|
summary: 'captures magnetic intensity, flux, and field density.',
|
||||||
|
context: 'Use these units when comparing magnets, coils, or magnetic flux through areas and materials.',
|
||||||
|
},
|
||||||
power: {
|
power: {
|
||||||
summary: 'measures the rate at which energy is transferred or converted.',
|
summary: 'measures the rate at which energy is transferred or converted.',
|
||||||
context: 'Use them to compare engines, appliances, or systems that deliver energy over time.',
|
context: 'Use them to compare engines, appliances, or systems that deliver energy over time.',
|
||||||
@@ -75,7 +83,20 @@ const domainDefinitions: Record<string, { summary: string; context: string }> =
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const definitions: Record<string, Record<string, string>> = {};
|
const canonicalLabelAliases: Record<string, string> = {
|
||||||
|
pascals: 'Pascal',
|
||||||
|
gills: 'Gill',
|
||||||
|
newtons: 'Newton',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeLabel = (label?: string): string | undefined => {
|
||||||
|
if (!label) return undefined;
|
||||||
|
const trimmed = label.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const alias = canonicalLabelAliases[trimmed.toLowerCase()];
|
||||||
|
return alias ?? trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
const categoryPriority = [...Object.keys(domainDefinitions)];
|
const categoryPriority = [...Object.keys(domainDefinitions)];
|
||||||
|
|
||||||
const buildDefinition = (label: string, categoryKey: string): string => {
|
const buildDefinition = (label: string, categoryKey: string): string => {
|
||||||
@@ -85,16 +106,36 @@ const buildDefinition = (label: string, categoryKey: string): string => {
|
|||||||
return `${label} ${description}`;
|
return `${label} ${description}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 => {
|
calculators.forEach(calc => {
|
||||||
const { category, labels } = calc;
|
const { category, labels } = calc;
|
||||||
Object.values(labels).forEach(label => {
|
Object.values(labels).forEach(label => {
|
||||||
if (!label) return;
|
const normalized = normalizeLabel(label);
|
||||||
const bucket = definitions[label] || {};
|
if (!normalized) return;
|
||||||
const text = buildDefinition(label, category);
|
const bucket = defs[normalized] || {};
|
||||||
|
const text = buildDefinition(normalized, category);
|
||||||
bucket[category] = text;
|
bucket[category] = text;
|
||||||
definitions[label] = bucket;
|
defs[normalized] = bucket;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
definitions = defs;
|
||||||
|
});
|
||||||
|
|
||||||
|
await buildPromise;
|
||||||
|
return definitions!;
|
||||||
|
}
|
||||||
|
|
||||||
const findByPriority = (entries: Record<string, string>, preferred?: string): string | undefined => {
|
const findByPriority = (entries: Record<string, string>, preferred?: string): string | undefined => {
|
||||||
if (!entries) return undefined;
|
if (!entries) return undefined;
|
||||||
@@ -106,9 +147,10 @@ const findByPriority = (entries: Record<string, string>, preferred?: string): st
|
|||||||
return fallback.length ? fallback[0] : undefined;
|
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 entries = definitions[label];
|
const normalized = normalizeLabel(label);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
const defs = await ensureBuilt();
|
||||||
|
const entries = defs[normalized];
|
||||||
return findByPriority(entries, category);
|
return findByPriority(entries, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unitDefinitions = definitions;
|
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export interface SolveResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmt(n: number): string {
|
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 {
|
function gcd(a: number, b: number): number {
|
||||||
@@ -129,26 +135,47 @@ export function solve(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dec-frac':
|
case 'dec-frac': {
|
||||||
if (source === 1) {
|
// Two calculators share this type:
|
||||||
if (!isNaN(v1)) {
|
// - decimal -> fraction
|
||||||
const parts = v1.toString().split('.');
|
// - 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 len = parts[1] ? parts[1].length : 0;
|
||||||
const den = Math.pow(10, len);
|
const den = Math.pow(10, len);
|
||||||
const num = v1 * den;
|
const num = n * den;
|
||||||
const div = gcd(num, den);
|
const div = gcd(num, den);
|
||||||
out.val2 = `${num / div}/${den / div}`;
|
return `${num / div}/${den / div}`;
|
||||||
} else { out.val2 = ''; }
|
};
|
||||||
} else {
|
|
||||||
const parts = rawVal2.split('/');
|
const fractionToDecimal = (raw: string) => {
|
||||||
|
const parts = raw.split('/');
|
||||||
if (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1])) && Number(parts[1]) !== 0) {
|
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]));
|
||||||
} else {
|
}
|
||||||
const f = parseFloat(parts[0]);
|
const f = parseFloat(parts[0]);
|
||||||
out.val1 = !isNaN(f) ? f.toString() : '';
|
return !isNaN(f) ? f.toString() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fractionFirst) {
|
||||||
|
if (source === 1) {
|
||||||
|
out.val2 = fractionToDecimal(rawVal1);
|
||||||
|
} else {
|
||||||
|
out.val1 = decimalToFraction(v2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (source === 1) {
|
||||||
|
out.val2 = decimalToFraction(v1);
|
||||||
|
} else {
|
||||||
|
out.val1 = fractionToDecimal(rawVal2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'db-int':
|
case 'db-int':
|
||||||
if (source === 1) {
|
if (source === 1) {
|
||||||
@@ -181,6 +208,296 @@ export function solve(
|
|||||||
out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2)) : '';
|
out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2)) : '';
|
||||||
}
|
}
|
||||||
break;
|
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;
|
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)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
46
hdyc-svelte/src/lib/seo.ts
Normal file
46
hdyc-svelte/src/lib/seo.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const SITE_URL = 'https://howdoyouconvert.com';
|
||||||
|
export const SITE_NAME = 'HowDoYouConvert.com';
|
||||||
|
export const DEFAULT_ROBOTS = 'index,follow';
|
||||||
|
export const DEFAULT_TWITTER_CARD = 'summary';
|
||||||
|
|
||||||
|
type OpenGraphType = 'website' | 'article';
|
||||||
|
|
||||||
|
type SeoInput = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pathname: string;
|
||||||
|
type?: OpenGraphType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePathname = (pathname: string): string => {
|
||||||
|
const pathWithSlash = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||||
|
const normalized = pathWithSlash.replace(/\/{2,}/g, '/');
|
||||||
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||||
|
return normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canonicalUrl = (pathname: string): string => `${SITE_URL}${normalizePathname(pathname)}`;
|
||||||
|
|
||||||
|
export const buildSeoMeta = ({ title, description, pathname, type = 'website' }: SeoInput) => {
|
||||||
|
const canonical = canonicalUrl(pathname);
|
||||||
|
return {
|
||||||
|
canonical,
|
||||||
|
robots: DEFAULT_ROBOTS,
|
||||||
|
og: {
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url: canonical,
|
||||||
|
siteName: SITE_NAME,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: DEFAULT_TWITTER_CARD,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toJsonLd = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c');
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,263 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||||
|
import { palettes, type ThemeMode, type Palette } from '$lib/palettes';
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark';
|
const matomoContainerSrc = 'https://matomo.howdoyouconvert.com/js/container_B3r877Kn.js';
|
||||||
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 WindowWithAnalytics = Window & {
|
||||||
|
_mtm?: Array<Record<string, unknown>>;
|
||||||
type Palette = {
|
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||||
slug: string;
|
cancelIdleCallback?: (handle: number) => void;
|
||||||
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': '#2b2509',
|
|
||||||
'text-muted': '#6d5f2a',
|
|
||||||
'accent': '#facc15',
|
|
||||||
'accent-dark': '#b45309',
|
|
||||||
'accent-glow': 'rgba(250, 204, 21, 0.2)',
|
|
||||||
'accent-gradient': 'linear-gradient(135deg, #facc15, #f97316)',
|
|
||||||
'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)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let sidebarOpen = false;
|
let sidebarOpen = false;
|
||||||
|
let headerSearchOpen = false;
|
||||||
|
let isMobileHeader = false;
|
||||||
let theme: ThemeMode = 'dark';
|
let theme: ThemeMode = 'dark';
|
||||||
let selectedPaletteIndex = 0;
|
let selectedPaletteIndex = 0;
|
||||||
|
let savedScrollRestoration: ScrollRestoration | null = null;
|
||||||
$: isHomepage = $page.url.pathname === '/';
|
$: isHomepage = $page.url.pathname === '/';
|
||||||
$: if (isHomepage && sidebarOpen) {
|
$: if (isHomepage && (sidebarOpen || headerSearchOpen)) {
|
||||||
sidebarOpen = false;
|
sidebarOpen = false;
|
||||||
|
headerSearchOpen = false;
|
||||||
|
}
|
||||||
|
$: if (!isMobileHeader && headerSearchOpen) {
|
||||||
|
headerSearchOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyPalette = (index: number, persist = false) => {
|
const applyPalette = (index: number, persist = false) => {
|
||||||
@@ -289,8 +61,55 @@
|
|||||||
applyPalette(index, true);
|
applyPalette(index, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleHeaderSearch = () => {
|
||||||
|
headerSearchOpen = !headerSearchOpen;
|
||||||
|
if (headerSearchOpen) {
|
||||||
|
sidebarOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMatomoContainer = () => {
|
||||||
|
if (!browser) return;
|
||||||
|
if (document.querySelector(`script[src="${matomoContainerSrc}"]`)) return;
|
||||||
|
|
||||||
|
const appWindow = window as WindowWithAnalytics;
|
||||||
|
const queue = appWindow._mtm ?? [];
|
||||||
|
appWindow._mtm = queue;
|
||||||
|
queue.push({ 'mtm.startTime': Date.now(), event: 'mtm.Start' });
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = matomoContainerSrc;
|
||||||
|
script.setAttribute('data-cfasync', 'false');
|
||||||
|
document.head.appendChild(script);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
if (!browser) return;
|
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;
|
||||||
|
|
||||||
const savedTheme = window.localStorage.getItem('theme') as ThemeMode | null;
|
const savedTheme = window.localStorage.getItem('theme') as ThemeMode | null;
|
||||||
const savedPalette = window.localStorage.getItem('palette');
|
const savedPalette = window.localStorage.getItem('palette');
|
||||||
@@ -307,12 +126,68 @@
|
|||||||
updateTheme(event.matches ? 'dark' : 'light');
|
updateTheme(event.matches ? 'dark' : 'light');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navBreakpoint = window.matchMedia('(max-width: 1024px)');
|
||||||
|
const handleNavBreakpoint = (event: MediaQueryListEvent) => {
|
||||||
|
if (event.matches) {
|
||||||
|
sidebarOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const headerBreakpoint = window.matchMedia('(max-width: 768px)');
|
||||||
|
const updateHeaderBreakpoint = (event?: MediaQueryListEvent) => {
|
||||||
|
const isCompact = event?.matches ?? headerBreakpoint.matches;
|
||||||
|
isMobileHeader = isCompact;
|
||||||
|
if (!isCompact) {
|
||||||
|
headerSearchOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleHeaderBreakpoint = (event: MediaQueryListEvent) => {
|
||||||
|
updateHeaderBreakpoint(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
sidebarOpen = false;
|
||||||
|
headerSearchOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
|
||||||
|
if (navBreakpoint.matches) {
|
||||||
|
sidebarOpen = false;
|
||||||
|
}
|
||||||
|
updateHeaderBreakpoint();
|
||||||
|
if (typeof appWindow.requestIdleCallback === 'function') {
|
||||||
|
idleCallbackId = appWindow.requestIdleCallback(loadMatomoContainer, { timeout: 3000 });
|
||||||
|
} else {
|
||||||
|
fallbackTimeoutId = window.setTimeout(loadMatomoContainer, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if ('removeEventListener' in mediaQuery) {
|
if ('removeEventListener' in mediaQuery) {
|
||||||
mediaQuery.removeEventListener('change', handlePreferenceChange);
|
mediaQuery.removeEventListener('change', handlePreferenceChange);
|
||||||
} else {
|
} else {
|
||||||
mediaQuery.removeListener(handlePreferenceChange);
|
mediaQuery.removeListener(handlePreferenceChange);
|
||||||
}
|
}
|
||||||
|
if ('removeEventListener' in navBreakpoint) {
|
||||||
|
navBreakpoint.removeEventListener('change', handleNavBreakpoint);
|
||||||
|
} else {
|
||||||
|
navBreakpoint.removeListener(handleNavBreakpoint);
|
||||||
|
}
|
||||||
|
if ('removeEventListener' in headerBreakpoint) {
|
||||||
|
headerBreakpoint.removeEventListener('change', handleHeaderBreakpoint);
|
||||||
|
} else {
|
||||||
|
headerBreakpoint.removeListener(handleHeaderBreakpoint);
|
||||||
|
}
|
||||||
|
if (idleCallbackId !== null && typeof appWindow.cancelIdleCallback === 'function') {
|
||||||
|
appWindow.cancelIdleCallback(idleCallbackId);
|
||||||
|
}
|
||||||
|
if (fallbackTimeoutId !== null) {
|
||||||
|
window.clearTimeout(fallbackTimeoutId);
|
||||||
|
}
|
||||||
|
window.removeEventListener('keydown', handleEscape);
|
||||||
|
if (savedScrollRestoration !== null) {
|
||||||
|
window.history.scrollRestoration = savedScrollRestoration;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ('addEventListener' in mediaQuery) {
|
if ('addEventListener' in mediaQuery) {
|
||||||
@@ -321,46 +196,81 @@
|
|||||||
mediaQuery.addListener(handlePreferenceChange);
|
mediaQuery.addListener(handlePreferenceChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('addEventListener' in navBreakpoint) {
|
||||||
|
navBreakpoint.addEventListener('change', handleNavBreakpoint);
|
||||||
|
} else {
|
||||||
|
navBreakpoint.addListener(handleNavBreakpoint);
|
||||||
|
}
|
||||||
|
if ('addEventListener' in headerBreakpoint) {
|
||||||
|
headerBreakpoint.addEventListener('change', handleHeaderBreakpoint);
|
||||||
|
} else {
|
||||||
|
headerBreakpoint.addListener(handleHeaderBreakpoint);
|
||||||
|
}
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.png" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
|
||||||
<!-- Matomo Tag Manager -->
|
|
||||||
<script>
|
|
||||||
var _mtm = window._mtm = window._mtm || [];
|
|
||||||
_mtm.push({'mtm.startTime': (new Date().getTime()), 'event': 'mtm.Start'});
|
|
||||||
(function() {
|
|
||||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
|
||||||
g.async=true; g.src='https://matomo.howdoyouconvert.com/js/container_B3r877Kn.js'; s.parentNode.insertBefore(g,s);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<!-- End Matomo Tag Manager -->
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
<div class="header-left">
|
||||||
{#if !isHomepage}
|
{#if !isHomepage}
|
||||||
<button class="hamburger" on:click={() => (sidebarOpen = !sidebarOpen)} aria-label="Toggle menu">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hamburger"
|
||||||
|
on:click={() => {
|
||||||
|
sidebarOpen = !sidebarOpen;
|
||||||
|
if (sidebarOpen) {
|
||||||
|
headerSearchOpen = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
aria-controls="site-navigation"
|
||||||
|
aria-expanded={sidebarOpen ? 'true' : 'false'}
|
||||||
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<a href="/" class="site-logo">
|
<a href="/" class="site-logo">
|
||||||
<span>How Do You</span><span class="logo-accent">Convert</span><span style="opacity:0.4;font-weight:400">.com</span>
|
<span>How</span><span>Do</span><span>You</span><span class="logo-accent">Convert</span><span class="logo-domain">.com</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<SearchBar />
|
{#if !isHomepage}
|
||||||
|
<div class="desktop-header-search">
|
||||||
|
<SearchBar idPrefix="header-search" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="header-icon-btn search-toggle"
|
||||||
|
on:click={toggleHeaderSearch}
|
||||||
|
aria-controls="mobile-header-search"
|
||||||
|
aria-expanded={headerSearchOpen ? 'true' : 'false'}
|
||||||
|
aria-label={headerSearchOpen ? 'Close search' : 'Open search'}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🔍</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if !isHomepage}
|
||||||
|
<div id="mobile-header-search" class="mobile-header-search" class:open={headerSearchOpen} hidden={!headerSearchOpen}>
|
||||||
|
<SearchBar idPrefix="mobile-header-search" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="site-body">
|
<div class="site-body">
|
||||||
{#if !isHomepage}
|
{#if !isHomepage}
|
||||||
<Sidebar bind:open={sidebarOpen} />
|
<Sidebar bind:open={sidebarOpen} />
|
||||||
{/if}
|
{/if}
|
||||||
<main class="main-content">
|
<main id="main-content" class="main-content" tabindex="-1">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,72 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getCategoriesWithCounts, calculators } from '$lib/data/calculators';
|
import { categories, totalCalculators } from '$lib/data/stats';
|
||||||
import CategoryCard from '$lib/components/CategoryCard.svelte';
|
import CategoryCard from '$lib/components/CategoryCard.svelte';
|
||||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||||
|
import { buildSeoMeta, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||||
|
|
||||||
const cats = getCategoriesWithCounts();
|
const requiredCategoryFallbacks: Record<string, { label: string; icon: string }> = {
|
||||||
const totalCalculators = calculators.length;
|
fluids: { label: 'Fluids', icon: '💧' },
|
||||||
const totalCategories = cats.length;
|
magnetism: { label: 'Magnetism', icon: '🧲' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const homepageCategories = {
|
||||||
|
...requiredCategoryFallbacks,
|
||||||
|
...categories,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cats = Object.entries(homepageCategories).map(([key, meta]) => ({
|
||||||
|
key,
|
||||||
|
...meta,
|
||||||
|
}));
|
||||||
|
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({
|
||||||
|
title: pageTitle,
|
||||||
|
description: pageDescription,
|
||||||
|
pathname: '/',
|
||||||
|
});
|
||||||
|
const websiteJsonLd = toJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: SITE_NAME,
|
||||||
|
description: pageDescription,
|
||||||
|
url: SITE_URL,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>HowDoYouConvert.com — Free Unit Conversion Calculators</title>
|
<title>{pageTitle}</title>
|
||||||
<meta name="description" content="Convert between hundreds of units instantly. Free online calculators for length, weight, temperature, volume, area, speed, energy, power, data and more." />
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="robots" content={seo.robots} />
|
||||||
|
<link rel="canonical" href={seo.canonical} />
|
||||||
|
<meta property="og:type" content={seo.og.type} />
|
||||||
|
<meta property="og:title" content={seo.og.title} />
|
||||||
|
<meta property="og:description" content={seo.og.description} />
|
||||||
|
<meta property="og:url" content={seo.og.url} />
|
||||||
|
<meta property="og:site_name" content={seo.og.siteName} />
|
||||||
|
<meta name="twitter:card" content={seo.twitter.card} />
|
||||||
|
<meta name="twitter:title" content={seo.twitter.title} />
|
||||||
|
<meta name="twitter:description" content={seo.twitter.description} />
|
||||||
|
{@html `<script type="application/ld+json">${websiteJsonLd}</script>`}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>How Do You Convert?</h1>
|
<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">
|
<div class="search-center">
|
||||||
<SearchBar />
|
<SearchBar idPrefix="home-search" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-num">{totalCalculators}</div>
|
<div class="stat-num">{totalConversions}</div>
|
||||||
<div class="stat-label">Converters</div>
|
<div class="stat-label">Converters</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-num">{totalCategories}</div>
|
<div class="stat-num">{totalCategoriesCount}</div>
|
||||||
<div class="stat-label">Categories</div>
|
<div class="stat-label">Categories</div>
|
||||||
</div>
|
</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,27 +1,90 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Calculator from '$lib/components/Calculator.svelte';
|
import Calculator from '$lib/components/Calculator.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
$: calc = data.calculator;
|
$: calc = data.calculator;
|
||||||
$: related = data.related;
|
$: related = data.related;
|
||||||
|
$: pageTitle = `${calc.name} — ${SITE_NAME}`;
|
||||||
|
$: 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,
|
||||||
|
pathname: `/${calc.slug}`,
|
||||||
|
});
|
||||||
|
$: breadcrumbJsonLd = toJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: 'Home',
|
||||||
|
item: SITE_URL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: data.categoryLabel,
|
||||||
|
item: canonicalUrl(`/category/${calc.category}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 3,
|
||||||
|
name: calc.name,
|
||||||
|
item: seo.canonical,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
$: webPageJsonLd = toJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: calc.name,
|
||||||
|
description: pageDescription,
|
||||||
|
url: seo.canonical,
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{calc.name} — HowDoYouConvert.com</title>
|
<title>{pageTitle}</title>
|
||||||
<meta name="description" content="Convert {calc.labels.in1} to {calc.labels.in2} instantly with our free online calculator. Accurate bidirectional conversion with the exact formula shown." />
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="robots" content={seo.robots} />
|
||||||
|
<link rel="canonical" href={seo.canonical} />
|
||||||
|
<meta property="og:type" content={seo.og.type} />
|
||||||
|
<meta property="og:title" content={seo.og.title} />
|
||||||
|
<meta property="og:description" content={seo.og.description} />
|
||||||
|
<meta property="og:url" content={seo.og.url} />
|
||||||
|
<meta property="og:site_name" content={seo.og.siteName} />
|
||||||
|
<meta name="twitter:card" content={seo.twitter.card} />
|
||||||
|
<meta name="twitter:title" content={seo.twitter.title} />
|
||||||
|
<meta name="twitter:description" content={seo.twitter.description} />
|
||||||
|
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
|
||||||
|
{@html `<script type="application/ld+json">${webPageJsonLd}</script>`}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav class="breadcrumbs">
|
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||||
<a href="/">Home</a>
|
<ol>
|
||||||
<span class="sep">›</span>
|
<li><a href="/">Home</a></li>
|
||||||
<a href="/category/{calc.category}">{data.categoryIcon} {data.categoryLabel}</a>
|
<li><a href="/category/{calc.category}">{data.categoryIcon} {data.categoryLabel}</a></li>
|
||||||
<span class="sep">›</span>
|
<li aria-current="page">{calc.name}</li>
|
||||||
<span>{calc.name}</span>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Calculator config={calc} />
|
<h1 class="page-title calculator-page-title">{calc.name}</h1>
|
||||||
|
|
||||||
|
{#key calc.slug}
|
||||||
|
<Calculator config={calc} showTitle={false} />
|
||||||
|
{/key}
|
||||||
|
|
||||||
<div class="seo-content">
|
<div class="seo-content">
|
||||||
{#if calc.descriptionHTML}
|
{#if calc.descriptionHTML}
|
||||||
|
|||||||
@@ -1,18 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
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;
|
export let data: PageData;
|
||||||
|
|
||||||
|
$: pageTitle = `${data.label} Converters — ${SITE_NAME}`;
|
||||||
|
$: pageDescription = `Browse all ${data.label.toLowerCase()} unit converters. Free online calculators for converting between ${data.label.toLowerCase()} units.`;
|
||||||
|
$: categoryPath = `/category/${data.category}`;
|
||||||
|
$: seo = buildSeoMeta({
|
||||||
|
title: pageTitle,
|
||||||
|
description: pageDescription,
|
||||||
|
pathname: categoryPath,
|
||||||
|
});
|
||||||
|
$: breadcrumbJsonLd = toJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: 'Home',
|
||||||
|
item: SITE_URL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: `${data.label} Converters`,
|
||||||
|
item: seo.canonical,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
$: collectionJsonLd = toJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: `${data.label} Converters`,
|
||||||
|
description: pageDescription,
|
||||||
|
url: seo.canonical,
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
},
|
||||||
|
mainEntity: {
|
||||||
|
'@type': 'ItemList',
|
||||||
|
itemListElement: data.calculators.map((calc, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: calc.name,
|
||||||
|
url: canonicalUrl(`/${calc.slug}`),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.label} Converters — HowDoYouConvert.com</title>
|
<title>{pageTitle}</title>
|
||||||
<meta name="description" content="Browse all {data.label.toLowerCase()} unit converters. Free online calculators for converting between {data.label.toLowerCase()} units." />
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="robots" content={seo.robots} />
|
||||||
|
<link rel="canonical" href={seo.canonical} />
|
||||||
|
<meta property="og:type" content={seo.og.type} />
|
||||||
|
<meta property="og:title" content={seo.og.title} />
|
||||||
|
<meta property="og:description" content={seo.og.description} />
|
||||||
|
<meta property="og:url" content={seo.og.url} />
|
||||||
|
<meta property="og:site_name" content={seo.og.siteName} />
|
||||||
|
<meta name="twitter:card" content={seo.twitter.card} />
|
||||||
|
<meta name="twitter:title" content={seo.twitter.title} />
|
||||||
|
<meta name="twitter:description" content={seo.twitter.description} />
|
||||||
|
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
|
||||||
|
{@html `<script type="application/ld+json">${collectionJsonLd}</script>`}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav class="breadcrumbs">
|
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||||
<a href="/">Home</a>
|
<ol>
|
||||||
<span class="sep">›</span>
|
<li><a href="/">Home</a></li>
|
||||||
<span>{data.icon} {data.label}</span>
|
<li aria-current="page">{data.icon} {data.label}</li>
|
||||||
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1 class="page-title">{data.icon} {data.label} Converters</h1>
|
<h1 class="page-title">{data.icon} {data.label} Converters</h1>
|
||||||
@@ -23,8 +107,18 @@
|
|||||||
|
|
||||||
<div class="calc-list">
|
<div class="calc-list">
|
||||||
{#each data.calculators as calc}
|
{#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}
|
{calc.name}
|
||||||
|
{#if conversionRateText}
|
||||||
|
<span class="calc-list-tooltip" role="tooltip">{conversionRateText}</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { calculators } from '$lib/data/calculators';
|
import { calculators, categories } from '$lib/data/calculators';
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
const urls = calculators.map(
|
const calculatorUrls = calculators.map(
|
||||||
(calc) => `
|
(calc) => ` <url>
|
||||||
<url>
|
|
||||||
<loc>https://howdoyouconvert.com/${calc.slug}</loc>
|
<loc>https://howdoyouconvert.com/${calc.slug}</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>`
|
</url>`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const categoryUrls = Object.keys(categories).map(
|
||||||
|
(category) => ` <url>
|
||||||
|
<loc>https://howdoyouconvert.com/category/${category}</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>`
|
||||||
|
);
|
||||||
|
|
||||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
@@ -18,7 +25,8 @@ export const GET: RequestHandler = async () => {
|
|||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
${urls.join('')}
|
${categoryUrls.join('\n')}
|
||||||
|
${calculatorUrls.join('\n')}
|
||||||
</urlset>`;
|
</urlset>`;
|
||||||
|
|
||||||
return new Response(sitemap, {
|
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
21
hdyc-svelte/static/favicon.svg
Normal file
21
hdyc-svelte/static/favicon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Calculator favicon">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#115e59" />
|
||||||
|
<stop offset="1" stop-color="#0284c7" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#bg)" />
|
||||||
|
<rect x="17" y="8" width="30" height="48" rx="6" fill="#f8fafc" stroke="#0f172a" stroke-width="2" />
|
||||||
|
<rect x="22" y="14" width="20" height="9" rx="2" fill="#0f172a" />
|
||||||
|
<rect x="24" y="17" width="16" height="3" rx="1.5" fill="#67e8f9" />
|
||||||
|
<g fill="#0ea5e9">
|
||||||
|
<rect x="22" y="28" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="29" y="28" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="36" y="28" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="22" y="35" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="29" y="35" width="6" height="6" rx="1.5" />
|
||||||
|
<rect x="36" y="35" width="6" height="13" rx="1.5" fill="#f97316" />
|
||||||
|
<rect x="22" y="42" width="13" height="6" rx="1.5" fill="#14b8a6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
hdyc-svelte/static/fonts/inter/Inter-Bold.woff2
Normal file
BIN
hdyc-svelte/static/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
hdyc-svelte/static/fonts/inter/Inter-ExtraBold.woff2
Normal file
BIN
hdyc-svelte/static/fonts/inter/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
hdyc-svelte/static/fonts/inter/Inter-Medium.woff2
Normal file
BIN
hdyc-svelte/static/fonts/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
hdyc-svelte/static/fonts/inter/Inter-Regular.woff2
Normal file
BIN
hdyc-svelte/static/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
hdyc-svelte/static/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
hdyc-svelte/static/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
# allow crawling everything by default
|
# allow crawling everything by default
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Disallow:
|
||||||
|
Sitemap: https://howdoyouconvert.com/sitemap.xml
|
||||||
|
|||||||
284
migrate.py
284
migrate.py
@@ -1,10 +1,117 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
CALCLIST = BASE_DIR / 'calculators_list.md'
|
CALCLIST = BASE_DIR / 'calculators_list.md'
|
||||||
OUTPUT_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/calculators.ts'
|
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',
|
||||||
|
'weight',
|
||||||
|
'temperature',
|
||||||
|
'volume',
|
||||||
|
'fluids',
|
||||||
|
'area',
|
||||||
|
'speed',
|
||||||
|
'pressure',
|
||||||
|
'energy',
|
||||||
|
'magnetism',
|
||||||
|
'power',
|
||||||
|
'data',
|
||||||
|
'time',
|
||||||
|
'angle',
|
||||||
|
'number-systems',
|
||||||
|
'radiation',
|
||||||
|
'electrical',
|
||||||
|
'force',
|
||||||
|
'light',
|
||||||
|
'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():
|
def load_external_descriptions():
|
||||||
# Placeholder for future enrichment sources.
|
# Placeholder for future enrichment sources.
|
||||||
@@ -31,12 +138,14 @@ def parse_calculators_list():
|
|||||||
parts = [p.strip() for p in line.strip().strip('|').split('|')]
|
parts = [p.strip() for p in line.strip().strip('|').split('|')]
|
||||||
name_idx = header_map.get('Calculator Name')
|
name_idx = header_map.get('Calculator Name')
|
||||||
slug_idx = header_map.get('Slug')
|
slug_idx = header_map.get('Slug')
|
||||||
|
category_idx = header_map.get('Category')
|
||||||
factor_idx = header_map.get('Conversion Factor')
|
factor_idx = header_map.get('Conversion Factor')
|
||||||
if None not in (name_idx, slug_idx, factor_idx) and len(parts) > max(name_idx, slug_idx, factor_idx):
|
if None not in (name_idx, slug_idx, category_idx, factor_idx) and len(parts) > max(name_idx, slug_idx, category_idx, factor_idx):
|
||||||
name = parts[name_idx]
|
name = parts[name_idx]
|
||||||
slug = parts[slug_idx]
|
slug = parts[slug_idx]
|
||||||
|
category = parts[category_idx]
|
||||||
factor_raw = parts[factor_idx]
|
factor_raw = parts[factor_idx]
|
||||||
active_calcs.append((name, slug, factor_raw))
|
active_calcs.append((name, slug, category, factor_raw))
|
||||||
|
|
||||||
return active_calcs
|
return active_calcs
|
||||||
|
|
||||||
@@ -53,30 +162,10 @@ def split_conversion_name(name):
|
|||||||
return parts[0].strip(), parts[1].strip()
|
return parts[0].strip(), parts[1].strip()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def guess_category(name):
|
def normalize_category(raw: str) -> str:
|
||||||
name_l = name.lower()
|
normalized = raw.strip().lower().replace(' ', '-')
|
||||||
if any(x in name_l for x in ['acre-foot', 'acre-feet', 'acrefoot', 'acre feet']):
|
normalized = re.sub(r'[^a-z0-9-]', '', normalized)
|
||||||
return 'volume'
|
return normalized
|
||||||
if 'temp scale' in name_l or 'newton (temp' in name_l:
|
|
||||||
return 'temperature'
|
|
||||||
if any(x in name_l for x in ['force', 'torque', 'newton', 'dyne', 'foot-pound']): return 'force'
|
|
||||||
if any(x in name_l for x in ['acre', 'hectare', 'square']): return 'area'
|
|
||||||
if any(x in name_l for x in ['meter', 'inch', 'feet', 'yard', 'mile', 'cable', 'fathom', 'rod', 'chain', 'nautical', 'league']): return 'length'
|
|
||||||
if any(x in name_l for x in ['gram', 'pound', 'ounce', 'carat', 'stone', 'slug', 'ton', 'pennyweight', 'grain', 'momme']): return 'weight'
|
|
||||||
if any(x in name_l for x in ['celsius', 'fahrenheit', 'kelvin', 'rankine', 'delisle', 'reaumur', 'réaumur', 'romer', 'rømer']): return 'temperature'
|
|
||||||
if any(x in name_l for x in ['liter', 'gallon', 'cup', 'pint', 'quart', 'fluid', 'milliliter', 'spoon', 'drop']): return 'volume'
|
|
||||||
if ' per ' in name_l or 'knot' in name_l or 'mach' in name_l or 'rpm' in name_l: return 'speed' # RPM might be frequency, close enough
|
|
||||||
if any(x in name_l for x in ['pascal', 'bar', 'psi', 'atmosphere', 'mmhg', 'torr', 'water', 'mercury']): return 'pressure'
|
|
||||||
if any(x in name_l for x in ['joule', 'calorie', 'btu', 'erg', 'therm', 'electron-volt']): return 'energy'
|
|
||||||
if any(x in name_l for x in ['watt', 'horsepower']): return 'power'
|
|
||||||
if any(x in name_l for x in ['byte', 'bit', 'nibble', 'baud']): return 'data'
|
|
||||||
if 'light' in name_l or any(x in name_l for x in ['lumen', 'lux', 'candela']): return 'light'
|
|
||||||
if any(x in name_l for x in ['second', 'minute', 'hour', 'day', 'week', 'month', 'year']): return 'time'
|
|
||||||
if any(x in name_l for x in ['degree', 'radian', 'mil ', 'arc', 'gradian', 'quadrant']): return 'angle'
|
|
||||||
if any(x in name_l for x in ['binary', 'hex', 'octal', 'decimal', 'ascii', 'fraction']): return 'number-systems'
|
|
||||||
if any(x in name_l for x in ['becquerel', 'curie', 'gray', 'rad', 'sievert', 'rem', 'roentgen', 'rutherford']): return 'radiation'
|
|
||||||
if any(x in name_l for x in ['volt', 'amp', 'ohm', 'siemens', 'farad', 'henry', 'coulomb']): return 'electrical'
|
|
||||||
return 'other'
|
|
||||||
|
|
||||||
def process():
|
def process():
|
||||||
external_descriptions = load_external_descriptions()
|
external_descriptions = load_external_descriptions()
|
||||||
@@ -84,7 +173,9 @@ def process():
|
|||||||
|
|
||||||
calculators_ts_entries = []
|
calculators_ts_entries = []
|
||||||
|
|
||||||
for raw_name, slug, factor_raw in active_rows:
|
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:
|
if raw_name == 'Calculator Name' or not slug:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -97,14 +188,55 @@ def process():
|
|||||||
else:
|
else:
|
||||||
in1, in2 = "From", "To"
|
in1, in2 = "From", "To"
|
||||||
|
|
||||||
category = guess_category(display_name)
|
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}')
|
||||||
|
if category not in CATEGORY_SET:
|
||||||
|
raise ValueError(f'Unknown category \"{category_raw}\" resolved to \"{category}\" for {slug}')
|
||||||
|
if slug in seen_slugs:
|
||||||
|
continue
|
||||||
|
seen_slugs.add(slug)
|
||||||
desc_html = external_descriptions.get(slug, "")
|
desc_html = external_descriptions.get(slug, "")
|
||||||
|
|
||||||
c_type = 'standard'
|
c_type = 'standard'
|
||||||
factor_val = "1"
|
factor_val = "1"
|
||||||
offset_val = "0"
|
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'
|
c_type = 'inverse'
|
||||||
factor_val = "1"
|
factor_val = "1"
|
||||||
elif 'Multi-Variable' in factor_raw:
|
elif 'Multi-Variable' in factor_raw:
|
||||||
@@ -132,7 +264,7 @@ def process():
|
|||||||
c_type = 'base'
|
c_type = 'base'
|
||||||
elif 'Linear Offset' in factor_raw:
|
elif 'Linear Offset' in factor_raw:
|
||||||
# "Linear Offset (1.8x + 32)"
|
# "Linear Offset (1.8x + 32)"
|
||||||
m = re.search(r'Linear Offset \(([\d\./]+)x\s*([+-]\s*[\d\.]+)\)', factor_raw)
|
m = re.search(r'Linear Offset \(([-\d\./]+)x\s*([+-]\s*[\d\.]+)\)', factor_raw)
|
||||||
if m:
|
if m:
|
||||||
f_v = m.group(1)
|
f_v = m.group(1)
|
||||||
# handle frac
|
# handle frac
|
||||||
@@ -141,7 +273,7 @@ def process():
|
|||||||
factor_val = f_v
|
factor_val = f_v
|
||||||
offset_val = o_v
|
offset_val = o_v
|
||||||
else:
|
else:
|
||||||
m2 = re.search(r'Linear Offset \(([\d\./]+)x\)', factor_raw)
|
m2 = re.search(r'Linear Offset \(([-\d\./]+)x\)', factor_raw)
|
||||||
if m2: factor_val = m2.group(1)
|
if m2: factor_val = m2.group(1)
|
||||||
c_type = 'standard'
|
c_type = 'standard'
|
||||||
else:
|
else:
|
||||||
@@ -152,6 +284,11 @@ def process():
|
|||||||
except:
|
except:
|
||||||
pass
|
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
|
# Avoid escaping single quotes by using JSON or dict
|
||||||
entry = {
|
entry = {
|
||||||
'slug': slug,
|
'slug': slug,
|
||||||
@@ -165,11 +302,19 @@ def process():
|
|||||||
# Determine labels
|
# Determine labels
|
||||||
labels = {'in1': in1, 'in2': in2}
|
labels = {'in1': in1, 'in2': in2}
|
||||||
if c_type in ['3col', '3col-mul']:
|
if c_type in ['3col', '3col-mul']:
|
||||||
# generic 3rd label
|
# generic 3rd label; make it descriptive instead of the vague "Result"
|
||||||
if 'watts' in slug and 'amps' in slug: labels['in3'] = 'Volts'
|
if 'watts' in slug and 'amps' in slug:
|
||||||
elif 'lumens' in slug: labels['in3'] = 'Area (sq m)'
|
labels['in3'] = 'Volts'
|
||||||
elif 'moles' in slug: labels['in3'] = 'Molar Mass'
|
elif 'lumens' in slug:
|
||||||
else: labels['in3'] = 'Result'
|
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
|
entry['labels'] = labels
|
||||||
|
|
||||||
@@ -193,6 +338,19 @@ def process():
|
|||||||
if 'decimal' in slug:
|
if 'decimal' in slug:
|
||||||
if slug.startswith('decimal'): entry['fromBase'] = 10
|
if slug.startswith('decimal'): entry['fromBase'] = 10
|
||||||
else: entry['toBase'] = 10
|
else: entry['toBase'] = 10
|
||||||
|
if 'base-' in slug:
|
||||||
|
parts = slug.split('-')
|
||||||
|
if len(parts) >= 5 and parts[0] == 'base' and parts[2] == 'to' and parts[3] == 'base':
|
||||||
|
try:
|
||||||
|
entry.setdefault('fromBase', int(parts[1]))
|
||||||
|
entry.setdefault('toBase', int(parts[4]))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if 'base' in factor_raw.lower():
|
||||||
|
match = re.search(r'base\\s*(\\d+)\\s*(?:→|to)\\s*(?:base\\s*)?(\\d+)', factor_raw, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
entry.setdefault('fromBase', int(match.group(1)))
|
||||||
|
entry.setdefault('toBase', int(match.group(2)))
|
||||||
|
|
||||||
if category == 'data' and c_type == 'standard':
|
if category == 'data' and c_type == 'standard':
|
||||||
# Fix data scale names and factors
|
# Fix data scale names and factors
|
||||||
@@ -212,7 +370,7 @@ def process():
|
|||||||
def get_val(k):
|
def get_val(k):
|
||||||
if k in units_10: return 10, units_10[k]
|
if k in units_10: return 10, units_10[k]
|
||||||
if k in units_2: return 2, units_2[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
|
return None, None
|
||||||
|
|
||||||
b1, e1 = get_val(in1_key)
|
b1, e1 = get_val(in1_key)
|
||||||
@@ -250,6 +408,8 @@ def process():
|
|||||||
for e in calculators_ts_entries:
|
for e in calculators_ts_entries:
|
||||||
# Check if inverse exists. We hide the one with the smaller factor (usually < 1) or hide alphabetical later one.
|
# Check if inverse exists. We hide the one with the smaller factor (usually < 1) or hide alphabetical later one.
|
||||||
# But a better heuristic: reverse of split(' to ')
|
# But a better heuristic: reverse of split(' to ')
|
||||||
|
if e.get('category') == 'data':
|
||||||
|
continue
|
||||||
parsed = split_conversion_name(e['name'])
|
parsed = split_conversion_name(e['name'])
|
||||||
if parsed:
|
if parsed:
|
||||||
rev_name = f"{parsed[1]} to {parsed[0]}"
|
rev_name = f"{parsed[1]} to {parsed[0]}"
|
||||||
@@ -266,7 +426,7 @@ def process():
|
|||||||
# Ensure types are right
|
# Ensure types are right
|
||||||
# write to TS
|
# write to TS
|
||||||
out = """// THIS FILE IS AUTO-GENERATED BY migrate.py
|
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 {
|
export interface CalculatorDef {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -284,26 +444,12 @@ export interface CalculatorDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const categories: Record<string, { label: string; icon: string }> = {
|
export const categories: Record<string, { label: string; icon: string }> = {
|
||||||
length: { label: 'Length / Distance', icon: '📏' },
|
"""
|
||||||
weight: { label: 'Weight / Mass', icon: '⚖️' },
|
for k, v in CATEGORIES.items():
|
||||||
temperature: { label: 'Temperature', icon: '🌡️' },
|
out += f" '{k}': {json.dumps(v, ensure_ascii=False).replace('{', '{ ').replace('}', ' }')},\n"
|
||||||
volume: { label: 'Volume', icon: '🧪' },
|
out += "};\n"
|
||||||
area: { label: 'Area', icon: '📐' },
|
|
||||||
speed: { label: 'Speed / Velocity', icon: '💨' },
|
|
||||||
pressure: { label: 'Pressure', icon: '🔽' },
|
|
||||||
energy: { label: 'Energy', 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: '🔄' },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
out += """
|
||||||
export const calculators: CalculatorDef[] = [
|
export const calculators: CalculatorDef[] = [
|
||||||
"""
|
"""
|
||||||
for e in calculators_ts_entries:
|
for e in calculators_ts_entries:
|
||||||
@@ -318,8 +464,13 @@ export const calculators: CalculatorDef[] = [
|
|||||||
out += """
|
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 {
|
export function getCalculatorBySlug(slug: string): CalculatorDef | undefined {
|
||||||
return slugIndex.get(slug);
|
return slugIndex.get(slug);
|
||||||
}
|
}
|
||||||
@@ -351,5 +502,22 @@ export function searchCalculators(query: string): CalculatorDef[] {
|
|||||||
|
|
||||||
print(f"Generated {len(calculators_ts_entries)} calculators into calculators.ts")
|
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__':
|
if __name__ == '__main__':
|
||||||
process()
|
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()
|
||||||
85
tests/test_homepage_categories.py
Normal file
85
tests/test_homepage_categories.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MIGRATE_PY = ROOT / "migrate.py"
|
||||||
|
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||||
|
HOMEPAGE_SVELTE = ROOT / "hdyc-svelte" / "src" / "routes" / "+page.svelte"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_category_keys_from_migrate() -> list[str]:
|
||||||
|
text = MIGRATE_PY.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"CATEGORY_KEYS\s*=\s*\[(.*?)\]", text, flags=re.S)
|
||||||
|
if not match:
|
||||||
|
raise AssertionError("Could not find CATEGORY_KEYS in migrate.py")
|
||||||
|
block = match.group(1)
|
||||||
|
return re.findall(r"'([a-z0-9-]+)'", block)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_category_keys_from_generated_ts() -> list[str]:
|
||||||
|
text = CALCULATORS_TS.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"export const categories:[\s\S]*?=\s*\{([\s\S]*?)\n\};", text)
|
||||||
|
if not match:
|
||||||
|
raise AssertionError("Could not find categories object in calculators.ts")
|
||||||
|
block = match.group(1)
|
||||||
|
return re.findall(r"^\s*'?(?P<key>[a-z0-9-]+)'?\s*:", block, flags=re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_required_categories(category_keys: list[str]) -> None:
|
||||||
|
if "fluids" not in category_keys:
|
||||||
|
raise AssertionError("Missing 'fluids' category")
|
||||||
|
if "magnetism" not in category_keys:
|
||||||
|
raise AssertionError("Missing 'magnetism' category")
|
||||||
|
if len(category_keys) < 20:
|
||||||
|
raise AssertionError("Category count dropped below 20")
|
||||||
|
|
||||||
|
|
||||||
|
class HomepageCategoryRegressionTests(unittest.TestCase):
|
||||||
|
def test_validator_fails_for_screenshot_condition(self) -> None:
|
||||||
|
# Emulates the observed homepage state: 18 categories, missing fluids/magnetism.
|
||||||
|
screenshot_condition_keys = [
|
||||||
|
"length",
|
||||||
|
"weight",
|
||||||
|
"temperature",
|
||||||
|
"volume",
|
||||||
|
"area",
|
||||||
|
"speed",
|
||||||
|
"pressure",
|
||||||
|
"energy",
|
||||||
|
"power",
|
||||||
|
"data",
|
||||||
|
"time",
|
||||||
|
"angle",
|
||||||
|
"number-systems",
|
||||||
|
"radiation",
|
||||||
|
"electrical",
|
||||||
|
"force",
|
||||||
|
"light",
|
||||||
|
"other",
|
||||||
|
]
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_validate_required_categories(screenshot_condition_keys)
|
||||||
|
|
||||||
|
def test_generated_categories_include_fluids_and_magnetism(self) -> None:
|
||||||
|
migrate_keys = _extract_category_keys_from_migrate()
|
||||||
|
generated_keys = _extract_category_keys_from_generated_ts()
|
||||||
|
|
||||||
|
_validate_required_categories(migrate_keys)
|
||||||
|
_validate_required_categories(generated_keys)
|
||||||
|
|
||||||
|
def test_homepage_uses_canonical_categories_map(self) -> None:
|
||||||
|
text = HOMEPAGE_SVELTE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertIn("Object.entries(homepageCategories)", text)
|
||||||
|
self.assertIn("Object.keys(homepageCategories).length", text)
|
||||||
|
self.assertNotIn("getCategoriesWithCounts", text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
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