Compare commits
130 Commits
d5c6b8d3c2
...
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 |
9632
calculators_list.md
9632
calculators_list.md
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
||||
"start": "node build",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint:3col": "node scripts/lint-3col.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
|
||||
36
hdyc-svelte/scripts/lint-3col.mjs
Executable file
36
hdyc-svelte/scripts/lint-3col.mjs
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const file = path.resolve('src/lib/data/calculators.ts');
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const start = text.indexOf('[');
|
||||
const end = text.indexOf('];', start);
|
||||
if (start === -1 || end === -1) {
|
||||
console.error('Unable to locate calculators array in calculators.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
let arr = text.slice(start, end + 1).trim();
|
||||
arr = arr.replace(/,\s*\]$/, ']');
|
||||
let calculators;
|
||||
try {
|
||||
calculators = JSON.parse(arr);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse calculators.ts as JSON:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const offenders = calculators.filter(c =>
|
||||
['3col', '3col-mul'].includes(c.type) &&
|
||||
c.name.toLowerCase().includes(' to ') &&
|
||||
(!c.labels?.in3 || c.labels.in3.toLowerCase() === 'result')
|
||||
);
|
||||
|
||||
if (offenders.length) {
|
||||
console.error(`3-col calculators with vague or missing output labels: ${offenders.length}`);
|
||||
offenders.slice(0, 20).forEach(c => console.error(`- ${c.slug}`));
|
||||
if (offenders.length > 20) console.error(`…and ${offenders.length - 20} more`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('3-col label lint passed.');
|
||||
15730
hdyc-svelte/sitemap.xml
Normal file
15730
hdyc-svelte/sitemap.xml
Normal file
File diff suppressed because it is too large
Load Diff
116
hdyc-svelte/sitemap.xsd
Normal file
116
hdyc-svelte/sitemap.xsd
Normal file
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
elementFormDefault="qualified">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
XML Schema for Sitemap files.
|
||||
Last Modifed 2008-03-26
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
|
||||
<xsd:element name="urlset">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
Container for a set of up to 50,000 document elements.
|
||||
This is the root element of the XML file.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
|
||||
<xsd:element name="url" type="tUrl" maxOccurs="unbounded"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="tUrl">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
Container for the data needed to describe a document to crawl.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="loc" type="tLoc"/>
|
||||
<xsd:element name="lastmod" type="tLastmod" minOccurs="0"/>
|
||||
<xsd:element name="changefreq" type="tChangeFreq" minOccurs="0"/>
|
||||
<xsd:element name="priority" type="tPriority" minOccurs="0"/>
|
||||
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:simpleType name="tLoc">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
REQUIRED: The location URI of a document.
|
||||
The URI must conform to RFC 2396 (http://www.ietf.org/rfc/rfc2396.txt).
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:restriction base="xsd:anyURI">
|
||||
<xsd:minLength value="12"/>
|
||||
<xsd:maxLength value="2048"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="tLastmod">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
OPTIONAL: The date the document was last modified. The date must conform
|
||||
to the W3C DATETIME format (http://www.w3.org/TR/NOTE-datetime).
|
||||
Example: 2005-05-10
|
||||
Lastmod may also contain a timestamp.
|
||||
Example: 2005-05-10T17:33:30+08:00
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:union>
|
||||
<xsd:simpleType>
|
||||
<xsd:restriction base="xsd:date"/>
|
||||
</xsd:simpleType>
|
||||
<xsd:simpleType>
|
||||
<xsd:restriction base="xsd:dateTime"/>
|
||||
</xsd:simpleType>
|
||||
</xsd:union>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="tChangeFreq">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
OPTIONAL: Indicates how frequently the content at a particular URL is
|
||||
likely to change. The value "always" should be used to describe
|
||||
documents that change each time they are accessed. The value "never"
|
||||
should be used to describe archived URLs. Please note that web
|
||||
crawlers may not necessarily crawl pages marked "always" more often.
|
||||
Consider this element as a friendly suggestion and not a command.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="always"/>
|
||||
<xsd:enumeration value="hourly"/>
|
||||
<xsd:enumeration value="daily"/>
|
||||
<xsd:enumeration value="weekly"/>
|
||||
<xsd:enumeration value="monthly"/>
|
||||
<xsd:enumeration value="yearly"/>
|
||||
<xsd:enumeration value="never"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="tPriority">
|
||||
<xsd:annotation>
|
||||
<xsd:documentation>
|
||||
OPTIONAL: The priority of a particular URL relative to other pages
|
||||
on the same site. The value for this element is a number between
|
||||
0.0 and 1.0 where 0.0 identifies the lowest priority page(s).
|
||||
The default priority of a page is 0.5. Priority is used to select
|
||||
between pages on your site. Setting a priority of 1.0 for all URLs
|
||||
will not help you, as the relative priority of pages on your site
|
||||
is what will be considered.
|
||||
</xsd:documentation>
|
||||
</xsd:annotation>
|
||||
<xsd:restriction base="xsd:decimal">
|
||||
<xsd:minInclusive value="0.0"/>
|
||||
<xsd:maxInclusive value="1.0"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
</xsd:schema>
|
||||
@@ -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 {
|
||||
/* ─── Colors (Dark Theme) ─────────────────────────────── */
|
||||
@@ -246,6 +308,28 @@ a {
|
||||
a:hover {
|
||||
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 ───────────────────────────────────────── */
|
||||
|
||||
@@ -259,37 +343,89 @@ a:hover {
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
background: var(--header-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.site-header {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-logo .logo-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
.site-logo .logo-domain {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
@@ -328,9 +464,7 @@ a:hover {
|
||||
.palette-dots {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
max-width: 38px;
|
||||
transition: max-width 0.2s ease, gap 0.2s ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.palette-dot {
|
||||
width: 30px;
|
||||
@@ -341,14 +475,14 @@ a:hover {
|
||||
background-size: 160%;
|
||||
background-position: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s, opacity 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);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
order: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
.palette-dot:hover {
|
||||
transform: translateY(-1px);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.palette-dot.active,
|
||||
.palette-dot:focus-visible {
|
||||
@@ -358,27 +492,9 @@ a:hover {
|
||||
.palette-dot:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.floating-palette-controls:not(:hover):not(:focus-within) .palette-dots {
|
||||
gap: 0;
|
||||
}
|
||||
.floating-palette-controls:not(:hover):not(:focus-within) .palette-dot:not(.active) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.floating-palette-controls:not(:hover):not(:focus-within) .palette-dot.active {
|
||||
order: -1;
|
||||
}
|
||||
.floating-palette-controls:hover .palette-dots,
|
||||
.floating-palette-controls:focus-within .palette-dots {
|
||||
max-width: 360px;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.floating-palette-controls:hover .palette-dot:not(.active),
|
||||
.floating-palette-controls:focus-within .palette-dot:not(.active) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.floating-palette-controls {
|
||||
gap: 0.2rem;
|
||||
@@ -393,12 +509,25 @@ a:hover {
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
color: var(--text);
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.2rem;
|
||||
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 {
|
||||
@@ -417,6 +546,9 @@ a:hover {
|
||||
padding: clamp(1.5rem, 2vw, 3rem);
|
||||
width: 100%;
|
||||
}
|
||||
.main-content:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 2rem;
|
||||
@@ -429,22 +561,35 @@ a:hover {
|
||||
/* ─── Page Utilities ─────────────────────────────────────── */
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.breadcrumbs a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.breadcrumbs .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
@@ -457,6 +602,10 @@ a:hover {
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.calculator-page-title {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
@@ -493,26 +642,44 @@ a:hover {
|
||||
}
|
||||
.category-grid {
|
||||
width: min(1160px, 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(132px, auto);
|
||||
gap: clamp(0.75rem, 1.3vw, 1.25rem);
|
||||
}
|
||||
.category-grid .category-card {
|
||||
flex: 0 1 clamp(180px, 22vw, 220px);
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
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) ────────────────────── */
|
||||
|
||||
.calc-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
width: 100%;
|
||||
max-width: calc(4 * 360px + 3 * 0.75rem);
|
||||
margin: 0 auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.calc-list-item {
|
||||
display: block;
|
||||
position: relative;
|
||||
--calc-tooltip-left: 50%;
|
||||
--calc-tooltip-translate: -0.35rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -528,6 +695,40 @@ a:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
color: var(--accent);
|
||||
z-index: 20;
|
||||
}
|
||||
.calc-list-item:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
z-index: 20;
|
||||
}
|
||||
.calc-list-tooltip {
|
||||
position: absolute;
|
||||
bottom: var(--calc-tooltip-bottom, calc(100% + 0.4rem));
|
||||
top: var(--calc-tooltip-top, auto);
|
||||
left: var(--calc-tooltip-left, 50%);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 92%, black 8%);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translate(-50%, calc(var(--calc-tooltip-translate, -0.35rem) + 0.15rem));
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
.calc-list-item:hover .calc-list-tooltip,
|
||||
.calc-list-item:focus-visible .calc-list-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate(-50%, var(--calc-tooltip-translate, -0.35rem));
|
||||
}
|
||||
|
||||
/* ─── Related Converters ─────────────────────────────────── */
|
||||
@@ -552,6 +753,11 @@ a:hover {
|
||||
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 ────────────────────────────────────────── */
|
||||
|
||||
@@ -631,7 +837,7 @@ a:hover {
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hamburger {
|
||||
display: block;
|
||||
display: inline-flex;
|
||||
}
|
||||
.site-body {
|
||||
gap: 1rem;
|
||||
@@ -639,10 +845,51 @@ a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.breadcrumbs {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
@@ -651,18 +898,31 @@ a:hover {
|
||||
}
|
||||
.category-section__inner {
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.category-section .section-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.category-grid {
|
||||
width: min(960px, 100%);
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
margin-inline: auto;
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
gap: clamp(0.7rem, 3vw, 1rem);
|
||||
justify-content: center;
|
||||
}
|
||||
.category-grid .category-card {
|
||||
flex: 0 1 clamp(150px, 40vw, 200px);
|
||||
min-width: 150px;
|
||||
max-width: 200px;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.stats-row {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.seo-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.site-body {
|
||||
gap: 1rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
@@ -2,11 +2,46 @@
|
||||
<html lang="en" data-theme="dark" data-palette="classic">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter/Inter-Regular.woff2"
|
||||
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>
|
||||
(function () {
|
||||
try {
|
||||
const doc = document.documentElement;
|
||||
try {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const savedPalette = localStorage.getItem('palette');
|
||||
const prefersDark =
|
||||
@@ -21,8 +56,9 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
<!-- SvelteKit head tags moved to top of <head> -->
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
@@ -13,18 +13,63 @@ const MIME_TYPES: Record<string, string> = {
|
||||
'.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 }) => {
|
||||
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 hasValidHeader = existing && existing.trim().length > 0;
|
||||
if (!hasValidHeader) {
|
||||
const extension = path.extname(event.url.pathname).toLowerCase();
|
||||
const extension = path.extname(pathname).toLowerCase();
|
||||
const mime = extension && MIME_TYPES[extension];
|
||||
if (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;
|
||||
|
||||
@@ -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,37 +2,52 @@
|
||||
import { solve } from '$lib/engine';
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { getConversionRateText } from '$lib/utils/conversionRate';
|
||||
import QuickDefinitionCard from '$lib/components/QuickDefinitionCard.svelte';
|
||||
import QuickConversionExample from '$lib/components/QuickConversionExample.svelte';
|
||||
import QuickConversionTable from '$lib/components/QuickConversionTable.svelte';
|
||||
|
||||
export let config: CalculatorDef;
|
||||
export let showTitle = true;
|
||||
|
||||
let val1 = '';
|
||||
let val2 = '';
|
||||
let val3 = '';
|
||||
let activeField: 1 | 2 | 3 = 1;
|
||||
let swapSnapshot: { val1: string; val2: string } | null = null;
|
||||
let isSwapFlipped = false;
|
||||
let swapState: { originalField: 1 | 2; originalValue: string | number | null } | null = null;
|
||||
let copyStatus: 'idle' | 'copied' | 'failed' = 'idle';
|
||||
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let tooltipFadeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let tooltipHideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let showCopyTooltip = false;
|
||||
let isTooltipFading = false;
|
||||
let showHoverTooltip = false;
|
||||
let footerControlsEl: HTMLDivElement | null = null;
|
||||
let tooltipX = 20;
|
||||
let copyStatusMessage = '';
|
||||
let initializedSlug: string | null = null;
|
||||
let conversionRateText: string | null = null;
|
||||
|
||||
$: has3 = ['3col', '3col-mul'].includes(config.type) || !!config.labels.in3;
|
||||
$: isTextInput = ['base', 'text-bin', 'bin-text', 'dec-frac', 'dms-dd', 'dd-dms'].includes(config.type);
|
||||
$: conversionRateText = getConversionRateText(config);
|
||||
|
||||
// Clear inputs on config (route) change
|
||||
$: if (config) {
|
||||
if (!paramsInitializing) clear();
|
||||
// Clear inputs only when navigating to a different calculator slug.
|
||||
$: if (config?.slug) {
|
||||
if (initializedSlug === null) {
|
||||
initializedSlug = config.slug;
|
||||
} else if (initializedSlug !== config.slug) {
|
||||
initializedSlug = config.slug;
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
let paramsInitializing = true;
|
||||
|
||||
function resetSwapState() {
|
||||
swapSnapshot = null;
|
||||
isSwapFlipped = false;
|
||||
function handleInput(source: 1 | 2 | 3, options?: { preserveSwap?: boolean }) {
|
||||
if (!options?.preserveSwap) {
|
||||
swapState = null;
|
||||
}
|
||||
|
||||
function handleInput(source: 1 | 2 | 3) {
|
||||
resetSwapState();
|
||||
activeField = source;
|
||||
const result = solve(config, source, val1, val2, val3);
|
||||
if (source !== 1) val1 = result.val1;
|
||||
@@ -41,41 +56,187 @@
|
||||
}
|
||||
|
||||
function swap() {
|
||||
if (isSwapFlipped && swapSnapshot) {
|
||||
val1 = swapSnapshot.val1;
|
||||
val2 = swapSnapshot.val2;
|
||||
resetSwapState();
|
||||
} else {
|
||||
swapSnapshot = { val1, val2 };
|
||||
[val1, val2] = [val2, val1];
|
||||
isSwapFlipped = true;
|
||||
if (!swapState) {
|
||||
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;
|
||||
}
|
||||
activeField = activeField === 1 ? 2 : 1;
|
||||
|
||||
const manualField = swapState.originalField;
|
||||
const manualValue = swapState.originalValue;
|
||||
if (manualField === 1) val1 = manualValue;
|
||||
else val2 = manualValue;
|
||||
swapState = null;
|
||||
handleInput(manualField, { preserveSwap: true });
|
||||
}
|
||||
|
||||
function clear() {
|
||||
val1 = '';
|
||||
val2 = '';
|
||||
val3 = '';
|
||||
resetSwapState();
|
||||
swapState = null;
|
||||
}
|
||||
|
||||
function buildShareUrl() {
|
||||
const params = new URLSearchParams();
|
||||
const v1 = toQueryValue(val1);
|
||||
const v2 = toQueryValue(val2);
|
||||
const v3 = toQueryValue(val3);
|
||||
const source: 1 | 2 | 3 = has3 ? activeField : (activeField === 2 ? 2 : 1);
|
||||
|
||||
if (!has3) {
|
||||
const sourceValue = source === 1 ? v1 : v2;
|
||||
if (sourceValue !== null) {
|
||||
params.set(source === 1 ? 'v1' : 'v2', sourceValue);
|
||||
}
|
||||
} else if (source === 3) {
|
||||
if (v2 !== null) params.set('v2', v2);
|
||||
if (v3 !== null) params.set('v3', v3);
|
||||
} else {
|
||||
if (v1 !== null) params.set('v1', v1);
|
||||
if (v2 !== null) params.set('v2', v2);
|
||||
}
|
||||
|
||||
const shareUrl = new URL($page.url);
|
||||
shareUrl.search = params.toString();
|
||||
return shareUrl.toString();
|
||||
}
|
||||
|
||||
function toQueryValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const stringValue = String(value);
|
||||
return stringValue.trim() ? stringValue : null;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (!copied) {
|
||||
throw new Error('execCommand copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerCopyTooltip() {
|
||||
if (tooltipFadeTimeout) clearTimeout(tooltipFadeTimeout);
|
||||
if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
|
||||
showCopyTooltip = true;
|
||||
isTooltipFading = false;
|
||||
tooltipFadeTimeout = setTimeout(() => {
|
||||
isTooltipFading = true;
|
||||
}, 900);
|
||||
tooltipHideTimeout = setTimeout(() => {
|
||||
showCopyTooltip = false;
|
||||
isTooltipFading = false;
|
||||
}, 1300);
|
||||
}
|
||||
|
||||
function updateTooltipPosition(event: MouseEvent) {
|
||||
if (!footerControlsEl) return;
|
||||
const rect = footerControlsEl.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
tooltipX = Math.max(12, Math.min(rect.width - 12, x));
|
||||
}
|
||||
|
||||
function positionTooltipFromButton(button: HTMLButtonElement) {
|
||||
if (!footerControlsEl) return;
|
||||
const controlsRect = footerControlsEl.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const centerX = buttonRect.left - controlsRect.left + buttonRect.width / 2;
|
||||
tooltipX = Math.max(12, Math.min(controlsRect.width - 12, centerX));
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!browser) return;
|
||||
const url = buildShareUrl();
|
||||
|
||||
try {
|
||||
await copyText(url);
|
||||
copyStatus = 'copied';
|
||||
triggerCopyTooltip();
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link', error);
|
||||
copyStatus = 'failed';
|
||||
} finally {
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout);
|
||||
}
|
||||
statusTimeout = setTimeout(() => {
|
||||
copyStatus = 'idle';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
$: copyStatusMessage =
|
||||
copyStatus === 'copied'
|
||||
? 'Link copied to clipboard'
|
||||
: copyStatus === 'failed'
|
||||
? 'Failed to copy link'
|
||||
: '';
|
||||
|
||||
onMount(() => {
|
||||
const params = new URLSearchParams($page.url.search);
|
||||
if (params.has('v1')) { val1 = params.get('v1')!; handleInput(1); }
|
||||
else if (params.has('v2')) { val2 = params.get('v2')!; handleInput(2); }
|
||||
else if (params.has('v3') && has3) { val3 = params.get('v3')!; handleInput(3); }
|
||||
setTimeout(() => { paramsInitializing = false; }, 0);
|
||||
const hasV1 = params.has('v1');
|
||||
const hasV2 = params.has('v2');
|
||||
const hasV3 = has3 && params.has('v3');
|
||||
|
||||
if (has3 && hasV2 && hasV3) {
|
||||
val2 = params.get('v2') ?? '';
|
||||
val3 = params.get('v3') ?? '';
|
||||
handleInput(3);
|
||||
} else if (has3 && hasV1 && hasV2) {
|
||||
val1 = params.get('v1') ?? '';
|
||||
val2 = params.get('v2') ?? '';
|
||||
handleInput(1);
|
||||
} else if (hasV1) {
|
||||
val1 = params.get('v1') ?? '';
|
||||
handleInput(1);
|
||||
} else if (hasV2) {
|
||||
val2 = params.get('v2') ?? '';
|
||||
handleInput(2);
|
||||
} else if (hasV3) {
|
||||
val3 = params.get('v3') ?? '';
|
||||
handleInput(3);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (statusTimeout) clearTimeout(statusTimeout);
|
||||
if (tooltipFadeTimeout) clearTimeout(tooltipFadeTimeout);
|
||||
if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="calculator-card">
|
||||
{#if showTitle || config.teaser}
|
||||
<div class="calc-header">
|
||||
{#if showTitle}
|
||||
<h2>{config.name}</h2>
|
||||
{/if}
|
||||
{#if config.teaser}
|
||||
<p class="calc-subtitle">{config.teaser}</p>
|
||||
<p class="calc-subtitle" class:no-title={!showTitle}>{config.teaser}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="calc-body" class:three-col={has3}>
|
||||
<div class="input-group">
|
||||
@@ -134,12 +295,54 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Clear
|
||||
</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">
|
||||
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
|
||||
{conversionRateText}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -181,6 +384,9 @@
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 400;
|
||||
}
|
||||
.calc-subtitle.no-title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.calc-body {
|
||||
display: grid;
|
||||
@@ -261,6 +467,10 @@
|
||||
background: var(--accent-dark);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.swap-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
.calc-footer {
|
||||
display: flex;
|
||||
@@ -269,6 +479,13 @@
|
||||
padding: 1rem 2rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.footer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
@@ -284,16 +501,97 @@
|
||||
color: #fff;
|
||||
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 {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
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) {
|
||||
.calc-header {
|
||||
padding: 1.2rem 1.2rem 0.9rem;
|
||||
}
|
||||
.calc-body {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.calc-body.three-col {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -307,5 +605,8 @@
|
||||
.swap-btn:hover {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
.calc-footer {
|
||||
padding: 0.9rem 1.25rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
min-height: 132px;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -22,8 +24,6 @@
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.category-card:hover {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { solve } from '$lib/engine';
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import { formatConversionValue } from '$lib/utils/formatConversionValue';
|
||||
|
||||
export let config: CalculatorDef;
|
||||
|
||||
@@ -17,40 +18,29 @@
|
||||
? solve(config, 1, exampleInput.toString(), '', '')
|
||||
: null;
|
||||
$: offset = config.offset ?? 0;
|
||||
$: formulaExpression = supportsExample
|
||||
? `${exampleInput} × ${config.factor}${offset ? ` + ${offset}` : ''}`
|
||||
$: hasOffset = Boolean(offset);
|
||||
$: formattedFactorValue = supportsExample
|
||||
? formatConversionValue(config.factor)
|
||||
: '';
|
||||
$: formattedOffsetValue = hasOffset
|
||||
? formatConversionValue(offset)
|
||||
: '';
|
||||
$: formulaExpression = supportsExample
|
||||
? `${exampleInput} × ${formattedFactorValue}${hasOffset ? ` + ${formattedOffsetValue}` : ''}`
|
||||
: '';
|
||||
|
||||
const formatExampleValue = (value: number | null): string => {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
const rounded = parseFloat(value.toFixed(6));
|
||||
if (rounded !== 0) {
|
||||
return rounded.toString();
|
||||
}
|
||||
const precise = value.toFixed(12).replace(/\.?0+$/, '');
|
||||
return precise || '0';
|
||||
};
|
||||
|
||||
$: reverseExampleValue =
|
||||
supportsExample && config.factor !== 0
|
||||
? (1 - offset) / config.factor
|
||||
: null;
|
||||
$: formattedReverseValue = formatExampleValue(reverseExampleValue);
|
||||
$: formattedReverseValue = formatConversionValue(reverseExampleValue);
|
||||
</script>
|
||||
|
||||
{#if supportsExample && result}
|
||||
<section class="example-card">
|
||||
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
|
||||
1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} {config.labels.in2}
|
||||
</p>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
|
||||
@@ -97,4 +87,11 @@
|
||||
font-weight: 600;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.example-card {
|
||||
margin: 0 1.25rem 1.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
type Row = { input: number; output: string };
|
||||
|
||||
const buildRow = (value: number): Row => {
|
||||
const formatted = solve(config, 1, value.toString(), '', '');
|
||||
const buildRow = (value: number, c: CalculatorDef): Row => {
|
||||
const formatted = solve(c, 1, value.toString(), '', '');
|
||||
return {
|
||||
input: value,
|
||||
output: formatted.val2 || '—'
|
||||
output: formatted.val2 || '—',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
let outputLabel = 'target units';
|
||||
|
||||
$: supportsTable = ['standard', 'inverse'].includes(config.type);
|
||||
$: rows = supportsTable
|
||||
? numericSamples.map(buildRow)
|
||||
$: rows = (config && supportsTable)
|
||||
? numericSamples.map(v => buildRow(v, config))
|
||||
: [];
|
||||
$: inputLabel = config.labels?.in1 ?? 'source units';
|
||||
$: outputLabel = config.labels?.in2 ?? 'target units';
|
||||
@@ -98,4 +98,17 @@
|
||||
.chart-output-unit {
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getDefinition } from '$lib/data/unitDefinitions';
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import type { CalculatorDef } from '$lib/data/calculatorLoader';
|
||||
|
||||
export let config: CalculatorDef;
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
|
||||
$: label1 = config.labels.in1 || 'Unit 1';
|
||||
$: label2 = config.labels.in2 || 'Unit 2';
|
||||
$: def1 = getDefinition(label1, config.category);
|
||||
$: def2 = getDefinition(label2, config.category);
|
||||
$: {
|
||||
getDefinition(label1, config.category).then(d => { def1 = d; });
|
||||
getDefinition(label2, config.category).then(d => { def2 = d; });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="definition-card">
|
||||
@@ -68,4 +70,14 @@
|
||||
font-size: 0.85rem;
|
||||
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>
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { searchCalculators } from '$lib/data/calculators';
|
||||
import { goto } from '$app/navigation';
|
||||
import { loadCalculators, searchCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
|
||||
|
||||
export let idPrefix = 'search';
|
||||
let query = '';
|
||||
let focused = false;
|
||||
let selectedIndex = -1;
|
||||
let lastQuery = '';
|
||||
let allCalcs: CalculatorDef[] = [];
|
||||
|
||||
$: results = query.length >= 2 ? searchCalculators(query).slice(0, 8) : [];
|
||||
$: if (query.length >= 1 && allCalcs.length === 0) {
|
||||
loadCalculators().then(data => { allCalcs = data; });
|
||||
}
|
||||
|
||||
$: results = (query.length >= 2 && allCalcs.length > 0) ? searchCalculators(allCalcs, query).slice(0, 8) : [];
|
||||
$: listboxId = `${idPrefix}-listbox`;
|
||||
$: inputId = `${idPrefix}-input`;
|
||||
$: isOpen = focused && results.length > 0;
|
||||
$: 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) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -33,12 +53,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-wrapper" class:active={focused && results.length > 0}>
|
||||
<div class="search-wrapper" class:active={isOpen}>
|
||||
<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">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
bind:value={query}
|
||||
on:focus={() => (focused = true)}
|
||||
@@ -46,6 +67,12 @@
|
||||
on:keydown={handleKeydown}
|
||||
placeholder="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}
|
||||
<button
|
||||
@@ -61,11 +88,12 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if focused && results.length > 0}
|
||||
<ul class="results" role="listbox" aria-label="Conversion suggestions">
|
||||
{#if isOpen}
|
||||
<ul class="results" id={listboxId} role="listbox" aria-label="Conversion suggestions">
|
||||
{#each results as result, i}
|
||||
<li>
|
||||
<button
|
||||
id={`${idPrefix}-option-${i}`}
|
||||
type="button"
|
||||
class="result-item"
|
||||
class:selected={i === selectedIndex}
|
||||
@@ -162,6 +190,10 @@
|
||||
.result-item.selected {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.result-item:focus-visible {
|
||||
outline: none;
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { categories, getCalculatorsByCategory, type CalculatorDef } from '$lib/data/calculators';
|
||||
import { categories } from '$lib/data/stats';
|
||||
import { loadCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
|
||||
|
||||
let allCalculators: CalculatorDef[] = [];
|
||||
let isLoaded = false;
|
||||
|
||||
async function loadData() {
|
||||
if (isLoaded || !browser) return;
|
||||
const data = await loadCalculators();
|
||||
allCalculators = data;
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
let expandedCategory = '';
|
||||
let expandedUnits: Record<string, string> = {};
|
||||
@@ -23,38 +34,85 @@
|
||||
|
||||
type UnitGroup = {
|
||||
label: string;
|
||||
conversions: CalculatorDef[];
|
||||
conversions: UnitConversionLink[];
|
||||
};
|
||||
|
||||
type UnitBucket = {
|
||||
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]) => {
|
||||
const buckets = new Map<string, UnitBucket>();
|
||||
const calcs = getCalculatorsByCategory(key);
|
||||
|
||||
if (!isLoaded) {
|
||||
return { key, meta, units: [] };
|
||||
}
|
||||
|
||||
const calcs = allCalculators.filter(c => c.category === key && !c.hidden);
|
||||
const canonicalByPair = new Map<string, CalculatorDef>();
|
||||
|
||||
calcs.forEach(calc => {
|
||||
[calc.labels.in1, calc.labels.in2].forEach(unit => {
|
||||
const key = unit.toLowerCase();
|
||||
const existing = buckets.get(key);
|
||||
if (existing) {
|
||||
existing.conversions.push(calc);
|
||||
} else {
|
||||
buckets.set(key, {
|
||||
label: unit,
|
||||
conversions: [calc],
|
||||
});
|
||||
const pairKey = toPairKey(calc.labels.in1, calc.labels.in2);
|
||||
const existing = canonicalByPair.get(pairKey);
|
||||
if (!existing || calc.slug.localeCompare(existing.slug) < 0) {
|
||||
canonicalByPair.set(pairKey, calc);
|
||||
}
|
||||
});
|
||||
|
||||
canonicalByPair.forEach(calc => {
|
||||
addConversion(buckets, calc.labels.in1, calc.labels.in2, calc.slug);
|
||||
addConversion(buckets, calc.labels.in2, calc.labels.in1, calc.slug);
|
||||
});
|
||||
|
||||
const units = [...buckets.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, bucket]) => ({
|
||||
label: bucket.label,
|
||||
conversions: bucket.conversions.slice().sort((a, b) => a.name.localeCompare(b.name)),
|
||||
conversions: sortConversionsForUnit(bucket.conversions),
|
||||
}));
|
||||
|
||||
return { key, meta, units };
|
||||
@@ -119,12 +177,36 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<aside class="sidebar" class:open id="site-navigation" aria-hidden={open ? 'false' : 'true'}>
|
||||
<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">
|
||||
<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>
|
||||
<nav aria-label="Calculator categories">
|
||||
{#each categoryUnitGroups as group}
|
||||
@@ -157,14 +239,14 @@
|
||||
</button>
|
||||
{#if expandedUnits[group.key] === unit.label}
|
||||
<ul class="unit-list">
|
||||
{#each unit.conversions as calc}
|
||||
{#each unit.conversions as conversion}
|
||||
<li>
|
||||
<a
|
||||
href="/{calc.slug}"
|
||||
class:current={currentPath === `/${calc.slug}`}
|
||||
aria-current={currentPath === `/${calc.slug}` ? 'page' : undefined}
|
||||
href="/{conversion.slug}"
|
||||
class:current={currentPath === `/${conversion.slug}`}
|
||||
aria-current={currentPath === `/${conversion.slug}` ? 'page' : undefined}
|
||||
>
|
||||
{calc.name}
|
||||
{conversion.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -188,10 +270,8 @@
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" on:click={() => (open = false)}></div>
|
||||
{#if open && !isDesktop}
|
||||
<button type="button" class="overlay" aria-label="Close sidebar" on:click={closeSidebar}></button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -231,6 +311,11 @@
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.close-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 0.5rem 0;
|
||||
@@ -253,6 +338,10 @@
|
||||
.cat-toggle:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.cat-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
.cat-toggle.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
@@ -294,6 +383,11 @@
|
||||
color: var(--accent);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.cat-list li a:focus-visible {
|
||||
outline: none;
|
||||
color: var(--accent);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.cat-list li a.current {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
@@ -325,6 +419,10 @@
|
||||
.unit-toggle:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.unit-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
.unit-toggle.expanded {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
@@ -350,23 +448,31 @@
|
||||
color: var(--accent);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
.unit-list li a:focus-visible {
|
||||
outline: none;
|
||||
color: var(--accent);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -300px;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
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);
|
||||
}
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.close-btn {
|
||||
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 }> = {
|
||||
length: {
|
||||
@@ -17,6 +17,10 @@ const domainDefinitions: Record<string, { summary: string; context: string }> =
|
||||
summary: 'measures three-dimensional capacity inside containers or spaces.',
|
||||
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: {
|
||||
summary: 'tracks two-dimensional surface coverage.',
|
||||
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.',
|
||||
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: {
|
||||
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.',
|
||||
@@ -89,7 +97,6 @@ const normalizeLabel = (label?: string): string | undefined => {
|
||||
return alias ?? trimmed;
|
||||
};
|
||||
|
||||
const definitions: Record<string, Record<string, string>> = {};
|
||||
const categoryPriority = [...Object.keys(domainDefinitions)];
|
||||
|
||||
const buildDefinition = (label: string, categoryKey: string): string => {
|
||||
@@ -99,17 +106,36 @@ const buildDefinition = (label: string, categoryKey: string): string => {
|
||||
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 => {
|
||||
const { category, labels } = calc;
|
||||
Object.values(labels).forEach(label => {
|
||||
const normalized = normalizeLabel(label);
|
||||
if (!normalized) return;
|
||||
const bucket = definitions[normalized] || {};
|
||||
const bucket = defs[normalized] || {};
|
||||
const text = buildDefinition(normalized, category);
|
||||
bucket[category] = text;
|
||||
definitions[normalized] = bucket;
|
||||
defs[normalized] = bucket;
|
||||
});
|
||||
});
|
||||
definitions = defs;
|
||||
});
|
||||
|
||||
await buildPromise;
|
||||
return definitions!;
|
||||
}
|
||||
|
||||
const findByPriority = (entries: Record<string, string>, preferred?: string): string | undefined => {
|
||||
if (!entries) return undefined;
|
||||
@@ -121,11 +147,10 @@ const findByPriority = (entries: Record<string, string>, preferred?: string): st
|
||||
return fallback.length ? fallback[0] : undefined;
|
||||
};
|
||||
|
||||
export function getDefinition(label: string, category?: string): string | undefined {
|
||||
export async function getDefinition(label: string, category?: string): Promise<string | undefined> {
|
||||
const normalized = normalizeLabel(label);
|
||||
if (!normalized) return undefined;
|
||||
const entries = definitions[normalized];
|
||||
const defs = await ensureBuilt();
|
||||
const entries = defs[normalized];
|
||||
return findByPriority(entries, category);
|
||||
}
|
||||
|
||||
export const unitDefinitions = definitions;
|
||||
|
||||
@@ -10,7 +10,13 @@ export interface SolveResult {
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return parseFloat(n.toFixed(6)).toString();
|
||||
if (!Number.isFinite(n)) return n.toString();
|
||||
if (n === 0) return '0';
|
||||
if (Math.abs(n) < 1e-6) {
|
||||
return n.toExponential(6);
|
||||
}
|
||||
const rounded = parseFloat(n.toFixed(6));
|
||||
return rounded.toString();
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
@@ -129,26 +135,47 @@ export function solve(
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dec-frac':
|
||||
if (source === 1) {
|
||||
if (!isNaN(v1)) {
|
||||
const parts = v1.toString().split('.');
|
||||
case 'dec-frac': {
|
||||
// Two calculators share this type:
|
||||
// - decimal -> fraction
|
||||
// - fraction -> decimal
|
||||
// Detect which direction the left field represents via its label.
|
||||
const fractionFirst = calc.labels.in1.toLowerCase().includes('fraction');
|
||||
|
||||
const decimalToFraction = (n: number) => {
|
||||
if (isNaN(n)) return '';
|
||||
const parts = n.toString().split('.');
|
||||
const len = parts[1] ? parts[1].length : 0;
|
||||
const den = Math.pow(10, len);
|
||||
const num = v1 * den;
|
||||
const num = n * den;
|
||||
const div = gcd(num, den);
|
||||
out.val2 = `${num / div}/${den / div}`;
|
||||
} else { out.val2 = ''; }
|
||||
} else {
|
||||
const parts = rawVal2.split('/');
|
||||
return `${num / div}/${den / div}`;
|
||||
};
|
||||
|
||||
const fractionToDecimal = (raw: string) => {
|
||||
const parts = raw.split('/');
|
||||
if (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1])) && Number(parts[1]) !== 0) {
|
||||
out.val1 = fmt(Number(parts[0]) / Number(parts[1]));
|
||||
} else {
|
||||
return fmt(Number(parts[0]) / Number(parts[1]));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
case 'db-int':
|
||||
if (source === 1) {
|
||||
@@ -181,6 +208,296 @@ export function solve(
|
||||
out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2)) : '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'awg': {
|
||||
const log92 = Math.log(92);
|
||||
const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39);
|
||||
const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92;
|
||||
const awgToCircularMils = (g: number) => 1000 * Math.pow(92, (36 - g) / 19.5);
|
||||
const circularMilsToAwg = (a: number) => 36 - 19.5 * Math.log(a / 1000) / log92;
|
||||
const awgToAreaMm2 = (g: number) => {
|
||||
const d = awgToDiameterMm(g);
|
||||
return Math.PI * Math.pow(d, 2) / 4;
|
||||
};
|
||||
const areaMm2ToAwg = (a: number) => {
|
||||
const d = Math.sqrt((4 * a) / Math.PI);
|
||||
return diameterMmToAwg(d);
|
||||
};
|
||||
|
||||
const slug = calc.slug;
|
||||
const formatAwg = (g: number) => isFinite(g) ? fmt(g) : '';
|
||||
const awgIsInput = calc.labels.in1.toLowerCase().includes('awg');
|
||||
const isCircular = slug.includes('circular-mils');
|
||||
const isArea = slug.includes('square-millimeters');
|
||||
|
||||
if (isCircular) {
|
||||
if (awgIsInput) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToCircularMils(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(circularMilsToAwg(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(circularMilsToAwg(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(awgToCircularMils(v2)) : '';
|
||||
}
|
||||
} else if (isArea) {
|
||||
if (awgIsInput) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToAreaMm2(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(areaMm2ToAwg(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(areaMm2ToAwg(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(awgToAreaMm2(v2)) : '';
|
||||
}
|
||||
} else {
|
||||
// diameter in millimeters
|
||||
if (awgIsInput) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToDiameterMm(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(diameterMmToAwg(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(diameterMmToAwg(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(awgToDiameterMm(v2)) : '';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'awg-swg': {
|
||||
const log92 = Math.log(92);
|
||||
const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39);
|
||||
const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92;
|
||||
const swgTable: Record<number, number> = {
|
||||
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.385, 6: 4.877,
|
||||
7: 4.47, 8: 4.064, 9: 3.658, 10: 3.251, 11: 2.946, 12: 2.642, 13: 2.337,
|
||||
14: 2.032, 15: 1.829, 16: 1.626, 17: 1.422, 18: 1.219, 19: 1.016,
|
||||
20: 0.914, 21: 0.813, 22: 0.711, 23: 0.61, 24: 0.559, 25: 0.508,
|
||||
26: 0.457, 27: 0.417, 28: 0.376, 29: 0.345, 30: 0.315, 31: 0.294,
|
||||
32: 0.274, 33: 0.254, 34: 0.234, 35: 0.213, 36: 0.193,
|
||||
37: 0.173, 38: 0.152, 39: 0.132, 40: 0.122, 41: 0.112, 42: 0.102,
|
||||
43: 0.091, 44: 0.081, 45: 0.071, 46: 0.061, 47: 0.051, 48: 0.04,
|
||||
49: 0.03, 50: 0.025
|
||||
};
|
||||
|
||||
const nearestSwg = (diamMm: number) => {
|
||||
let bestGauge = 0;
|
||||
let bestDiff = Number.POSITIVE_INFINITY;
|
||||
for (const [gStr, d] of Object.entries(swgTable)) {
|
||||
const diff = Math.abs(diamMm - d);
|
||||
if (diff < bestDiff) { bestDiff = diff; bestGauge = Number(gStr); }
|
||||
}
|
||||
return bestGauge;
|
||||
};
|
||||
|
||||
if (source === 1) {
|
||||
// AWG -> SWG
|
||||
if (!isNaN(v1)) {
|
||||
const diam = awgToDiameterMm(v1);
|
||||
out.val2 = fmt(nearestSwg(diam));
|
||||
} else { out.val2 = ''; }
|
||||
} else {
|
||||
// SWG -> AWG
|
||||
if (!isNaN(v2)) {
|
||||
const diam = swgTable[Math.round(v2)];
|
||||
if (diam) out.val1 = fmt(diameterMmToAwg(diam));
|
||||
else out.val1 = '';
|
||||
} else { out.val1 = ''; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ev-lux': {
|
||||
const isEvFirst = calc.labels.in1.toLowerCase().includes('ev');
|
||||
const toLux = (ev: number) => 2.5 * Math.pow(2, ev);
|
||||
const toEv = (lux: number) => lux > 0 ? Math.log(lux / 2.5) / Math.log(2) : NaN;
|
||||
if (isEvFirst) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(toLux(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(toEv(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(toEv(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(toLux(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'aov': {
|
||||
const sensorWidth = 36; // mm, full-frame horizontal
|
||||
const isFocalFirst = calc.labels.in1.toLowerCase().includes('focal');
|
||||
const toAov = (f: number) => f > 0 ? (2 * Math.atan(sensorWidth / (2 * f)) * 180 / Math.PI) : NaN;
|
||||
const toFocal = (angle: number) => {
|
||||
const radians = angle * Math.PI / 180;
|
||||
return Math.tan(radians / 2) !== 0 ? sensorWidth / (2 * Math.tan(radians / 2)) : NaN;
|
||||
};
|
||||
if (isFocalFirst) {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toAov(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toFocal(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toFocal(v1)) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toAov(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'brinell-rockwell': {
|
||||
// Approximate correlation for steels:
|
||||
// BHN = (1520000 - 4500 * HRC) / (100 - HRC)^2
|
||||
if (source === 1) {
|
||||
// Brinell to Rockwell C
|
||||
if (!isNaN(v1) && v1 > 0) {
|
||||
const a = v1;
|
||||
const disc = 4500 ** 2 + 4 * a * 1070000;
|
||||
const y = (4500 + Math.sqrt(disc)) / (2 * a);
|
||||
const hrc = 100 - y;
|
||||
out.val2 = fmt(hrc);
|
||||
} else {
|
||||
out.val2 = '';
|
||||
}
|
||||
} else {
|
||||
// Rockwell C to Brinell
|
||||
if (!isNaN(v2) && v2 < 100) {
|
||||
const h = v2;
|
||||
const bhn = (1520000 - 4500 * h) / Math.pow(100 - h, 2);
|
||||
out.val1 = fmt(bhn);
|
||||
} else {
|
||||
out.val1 = '';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'molarity': {
|
||||
const m = v1; // mol/L
|
||||
const gpl = v2; // grams/L
|
||||
const molarMass = v3; // g/mol
|
||||
|
||||
if (source === 1) {
|
||||
out.val2 = (!isNaN(m) && !isNaN(molarMass)) ? fmt(m * molarMass) : '';
|
||||
} else if (source === 2) {
|
||||
out.val1 = (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) ? fmt(gpl / molarMass) : '';
|
||||
} else {
|
||||
if (!isNaN(m) && !isNaN(molarMass)) out.val2 = fmt(m * molarMass);
|
||||
else if (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) out.val1 = fmt(gpl / molarMass);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rockwell-vickers': {
|
||||
const hrcToBhn = (h: number) => (1520000 - 4500 * h) / Math.pow(100 - h, 2);
|
||||
const bhnToHrc = (b: number) => {
|
||||
const disc = 4500 ** 2 + 4 * b * 1070000;
|
||||
const y = (4500 + Math.sqrt(disc)) / (2 * b);
|
||||
return 100 - y;
|
||||
};
|
||||
const bhnToHv = (b: number) => b * 0.95;
|
||||
const hvToBhn = (hv: number) => hv / 0.95;
|
||||
|
||||
if (source === 1) {
|
||||
const hrc = v1;
|
||||
if (!isNaN(hrc) && hrc < 100) {
|
||||
const hv = bhnToHv(hrcToBhn(hrc));
|
||||
out.val2 = fmt(hv);
|
||||
} else out.val2 = '';
|
||||
} else {
|
||||
const hv = v2;
|
||||
if (!isNaN(hv) && hv > 0) {
|
||||
const bhn = hvToBhn(hv);
|
||||
out.val1 = fmt(bhnToHrc(bhn));
|
||||
} else out.val1 = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sus-cst': {
|
||||
const susToCst = (sus: number) => {
|
||||
if (sus <= 0) return NaN;
|
||||
if (sus < 100) return 0.226 * sus - 195 / sus;
|
||||
return 0.22 * sus - 135 / sus;
|
||||
};
|
||||
const cstToSus = (cst: number) => {
|
||||
if (cst <= 0) return NaN;
|
||||
const low = (cst + Math.sqrt(cst * cst + 4 * 0.226 * 195)) / (2 * 0.226);
|
||||
const high = (cst + Math.sqrt(cst * cst + 4 * 0.22 * 135)) / (2 * 0.22);
|
||||
return low < 100 ? low : high;
|
||||
};
|
||||
|
||||
if (source === 1) {
|
||||
out.val2 = !isNaN(v1) ? fmt(susToCst(v1)) : '';
|
||||
} else {
|
||||
out.val1 = !isNaN(v2) ? fmt(cstToSus(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'swg': {
|
||||
const swgTable: Record<number, number> = {
|
||||
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47,
|
||||
8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83,
|
||||
16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61,
|
||||
24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29,
|
||||
32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14,
|
||||
40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058,
|
||||
48: 0.051, 49: 0.045, 50: 0.04
|
||||
};
|
||||
|
||||
const gaugeToMm = (g: number) => swgTable[Math.round(g)];
|
||||
const mmToGauge = (mm: number) => {
|
||||
let best = -1, bestDiff = Infinity;
|
||||
for (const [gStr, diam] of Object.entries(swgTable)) {
|
||||
const diff = Math.abs(mm - diam);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
if (calc.labels.in1.toLowerCase().includes('swg')) {
|
||||
if (source === 1) out.val2 = !isNaN(v1) ? fmt(gaugeToMm(v1) ?? NaN) : '';
|
||||
else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(mmToGauge(v2)) : '';
|
||||
} else {
|
||||
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(mmToGauge(v1)) : '';
|
||||
else out.val1 = !isNaN(v2) ? fmt(gaugeToMm(v2) ?? NaN) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cmil-dia': {
|
||||
const cmilToMm = (c: number) => 0.0254 * Math.sqrt(c);
|
||||
const mmToCmil = (mm: number) => Math.pow(mm / 0.0254, 2);
|
||||
|
||||
if (source === 1) {
|
||||
out.val2 = (!isNaN(v1) && v1 >= 0) ? fmt(cmilToMm(v1)) : '';
|
||||
} else {
|
||||
out.val1 = (!isNaN(v2) && v2 >= 0) ? fmt(mmToCmil(v2)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cmil-swg': {
|
||||
const swgTable: Record<number, number> = {
|
||||
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47,
|
||||
8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83,
|
||||
16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61,
|
||||
24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29,
|
||||
32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14,
|
||||
40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058,
|
||||
48: 0.051, 49: 0.045, 50: 0.04
|
||||
};
|
||||
const mmToSwg = (mm: number) => {
|
||||
let best = -1, bestDiff = Infinity;
|
||||
for (const [gStr, diam] of Object.entries(swgTable)) {
|
||||
const diff = Math.abs(mm - diam);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const swgToMm = (g: number) => swgTable[Math.round(g)];
|
||||
const cmilToMm = (c: number) => 0.0254 * Math.sqrt(c);
|
||||
const mmToCmil = (mm: number) => Math.pow(mm / 0.0254, 2);
|
||||
|
||||
if (source === 1) {
|
||||
const mm = (!isNaN(v1) && v1 >= 0) ? cmilToMm(v1) : NaN;
|
||||
out.val2 = isFinite(mm) ? fmt(mmToSwg(mm)) : '';
|
||||
} else {
|
||||
const mm = (!isNaN(v2) && v2 >= 0) ? swgToMm(v2) : NaN;
|
||||
out.val1 = isFinite(mm) ? fmt(mmToCmil(mm)) : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
244
hdyc-svelte/src/lib/palettes.ts
Normal file
244
hdyc-svelte/src/lib/palettes.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
export type PaletteVar =
|
||||
| 'bg'
|
||||
| 'bg-elevated'
|
||||
| 'sidebar-bg'
|
||||
| 'card-bg'
|
||||
| 'input-bg'
|
||||
| 'hover-bg'
|
||||
| 'border'
|
||||
| 'text'
|
||||
| 'text-muted'
|
||||
| 'accent'
|
||||
| 'accent-dark'
|
||||
| 'accent-glow'
|
||||
| 'accent-gradient'
|
||||
| 'header-bg';
|
||||
|
||||
export type PaletteTheme = Record<PaletteVar, string>;
|
||||
|
||||
export type Palette = {
|
||||
slug: string;
|
||||
label: string;
|
||||
light: PaletteTheme;
|
||||
dark: PaletteTheme;
|
||||
};
|
||||
|
||||
export const palettes: Palette[] = [
|
||||
{
|
||||
slug: 'classic',
|
||||
label: 'Classic',
|
||||
light: {
|
||||
bg: '#f8fafc',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#ffffff',
|
||||
'input-bg': 'rgba(15, 23, 42, 0.04)',
|
||||
'hover-bg': 'rgba(15, 23, 42, 0.08)',
|
||||
border: 'rgba(15, 23, 42, 0.12)',
|
||||
text: '#0f172a',
|
||||
'text-muted': '#475569',
|
||||
accent: '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.15)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
bg: '#0c0f14',
|
||||
'bg-elevated': '#12161e',
|
||||
'sidebar-bg': '#10141b',
|
||||
'card-bg': 'rgba(18, 22, 30, 0.85)',
|
||||
'input-bg': 'rgba(255, 255, 255, 0.04)',
|
||||
'hover-bg': 'rgba(255, 255, 255, 0.06)',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
text: '#e8ecf4',
|
||||
'text-muted': '#7b8498',
|
||||
accent: '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.15)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
|
||||
'header-bg': 'rgba(12, 15, 20, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'emerald',
|
||||
label: 'Emerald',
|
||||
light: {
|
||||
'bg': '#f6fbf9',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#ffffff',
|
||||
'input-bg': '#ecf7f1',
|
||||
'hover-bg': '#d5f0df',
|
||||
'border': 'rgba(4, 120, 87, 0.25)',
|
||||
'text': '#0b2c1f',
|
||||
'text-muted': '#4a6b5c',
|
||||
'accent': '#047857',
|
||||
'accent-dark': '#065f46',
|
||||
'accent-glow': 'rgba(4, 120, 87, 0.2)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #047857, #0ea5e9)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0b1313',
|
||||
'bg-elevated': 'rgba(4, 20, 15, 0.85)',
|
||||
'sidebar-bg': '#08110f',
|
||||
'card-bg': 'rgba(6, 19, 13, 0.75)',
|
||||
'input-bg': 'rgba(16, 185, 129, 0.08)',
|
||||
'hover-bg': 'rgba(16, 185, 129, 0.12)',
|
||||
'border': 'rgba(16, 185, 129, 0.35)',
|
||||
'text': '#e9fcea',
|
||||
'text-muted': '#9fdac4',
|
||||
'accent': '#10b981',
|
||||
'accent-dark': '#059669',
|
||||
'accent-glow': 'rgba(16, 185, 129, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #10b981, #0ea5e9)',
|
||||
'header-bg': 'rgba(12, 15, 20, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'sunset',
|
||||
label: 'Sunset',
|
||||
light: {
|
||||
'bg': '#fff8f2',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fff4ef',
|
||||
'input-bg': '#ffe3d8',
|
||||
'hover-bg': '#ffd3bf',
|
||||
'border': 'rgba(249, 115, 22, 0.25)',
|
||||
'text': '#3d1b0b',
|
||||
'text-muted': '#7a4a37',
|
||||
'accent': '#f97316',
|
||||
'accent-dark': '#c2410c',
|
||||
'accent-glow': 'rgba(249, 115, 22, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #f97316, #ec4899)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.96)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0f0505',
|
||||
'bg-elevated': 'rgba(15, 5, 5, 0.85)',
|
||||
'sidebar-bg': '#0c0404',
|
||||
'card-bg': 'rgba(19, 6, 6, 0.7)',
|
||||
'input-bg': 'rgba(251, 113, 133, 0.08)',
|
||||
'hover-bg': 'rgba(251, 113, 133, 0.14)',
|
||||
'border': 'rgba(251, 113, 133, 0.35)',
|
||||
'text': '#ffe7e0',
|
||||
'text-muted': '#f9a6aa',
|
||||
'accent': '#fb7185',
|
||||
'accent-dark': '#be123c',
|
||||
'accent-glow': 'rgba(251, 113, 133, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fb7185, #f97316)',
|
||||
'header-bg': 'rgba(12, 8, 6, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'ocean',
|
||||
label: 'Ocean',
|
||||
light: {
|
||||
'bg': '#f4fbff',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#f0f7ff',
|
||||
'input-bg': '#dcefff',
|
||||
'hover-bg': '#cae8ff',
|
||||
'border': 'rgba(14, 165, 233, 0.25)',
|
||||
'text': '#06274e',
|
||||
'text-muted': '#4d6993',
|
||||
'accent': '#0ea5e9',
|
||||
'accent-dark': '#0369a1',
|
||||
'accent-glow': 'rgba(14, 165, 233, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #0ea5e9, #4753ff)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#030b12',
|
||||
'bg-elevated': 'rgba(2, 9, 20, 0.85)',
|
||||
'sidebar-bg': '#050c16',
|
||||
'card-bg': 'rgba(3, 13, 26, 0.75)',
|
||||
'input-bg': 'rgba(14, 165, 233, 0.08)',
|
||||
'hover-bg': 'rgba(14, 165, 233, 0.15)',
|
||||
'border': 'rgba(14, 165, 233, 0.4)',
|
||||
'text': '#e6f6ff',
|
||||
'text-muted': '#a1c4e8',
|
||||
'accent': '#38bdf8',
|
||||
'accent-dark': '#0369a1',
|
||||
'accent-glow': 'rgba(14, 165, 233, 0.35)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #38bdf8, #0f172a)',
|
||||
'header-bg': 'rgba(6, 15, 30, 0.85)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'orchid',
|
||||
label: 'Orchid',
|
||||
light: {
|
||||
'bg': '#fdf6ff',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fdf2ff',
|
||||
'input-bg': '#f5e4ff',
|
||||
'hover-bg': '#e9d4ff',
|
||||
'border': 'rgba(168, 85, 247, 0.25)',
|
||||
'text': '#2c0a3a',
|
||||
'text-muted': '#6a5277',
|
||||
'accent': '#a855f7',
|
||||
'accent-dark': '#6d28d9',
|
||||
'accent-glow': 'rgba(168, 85, 247, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #c084fc, #a855f7)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.97)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#0c0215',
|
||||
'bg-elevated': 'rgba(10, 3, 30, 0.85)',
|
||||
'sidebar-bg': '#090118',
|
||||
'card-bg': 'rgba(12, 2, 25, 0.75)',
|
||||
'input-bg': 'rgba(168, 85, 247, 0.08)',
|
||||
'hover-bg': 'rgba(168, 85, 247, 0.16)',
|
||||
'border': 'rgba(168, 85, 247, 0.35)',
|
||||
'text': '#f5e6ff',
|
||||
'text-muted': '#c5a3e8',
|
||||
'accent': '#d946ef',
|
||||
'accent-dark': '#831843',
|
||||
'accent-glow': 'rgba(217, 70, 239, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #d946ef, #fb7185)',
|
||||
'header-bg': 'rgba(13, 6, 23, 0.95)',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'citrus',
|
||||
label: 'Citrus',
|
||||
light: {
|
||||
'bg': '#fffdf5',
|
||||
'bg-elevated': '#ffffff',
|
||||
'sidebar-bg': '#ffffff',
|
||||
'card-bg': '#fffaf0',
|
||||
'input-bg': '#fff4d8',
|
||||
'hover-bg': '#ffeec1',
|
||||
'border': 'rgba(250, 204, 21, 0.25)',
|
||||
'text': '#1f1505',
|
||||
'text-muted': '#5b4a1e',
|
||||
'accent': '#fbbf24',
|
||||
'accent-dark': '#c2410c',
|
||||
'accent-glow': 'rgba(250, 204, 21, 0.3)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #d97706)',
|
||||
'header-bg': 'rgba(255, 255, 255, 0.98)',
|
||||
},
|
||||
dark: {
|
||||
'bg': '#1a1203',
|
||||
'bg-elevated': 'rgba(26, 18, 3, 0.9)',
|
||||
'sidebar-bg': '#130e02',
|
||||
'card-bg': 'rgba(26, 18, 3, 0.75)',
|
||||
'input-bg': 'rgba(250, 204, 21, 0.08)',
|
||||
'hover-bg': 'rgba(250, 204, 21, 0.14)',
|
||||
'border': 'rgba(250, 204, 21, 0.35)',
|
||||
'text': '#fff8e7',
|
||||
'text-muted': '#f6dea1',
|
||||
'accent': '#fbbf24',
|
||||
'accent-dark': '#b45309',
|
||||
'accent-glow': 'rgba(250, 204, 21, 0.25)',
|
||||
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #f97316)',
|
||||
'header-bg': 'rgba(15, 9, 2, 0.9)',
|
||||
},
|
||||
},
|
||||
];
|
||||
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">
|
||||
import { browser } from '$app/environment';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import '../app.css';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import { palettes, type ThemeMode, type Palette } from '$lib/palettes';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
type PaletteVar =
|
||||
| 'bg'
|
||||
| 'bg-elevated'
|
||||
| 'sidebar-bg'
|
||||
| 'card-bg'
|
||||
| 'input-bg'
|
||||
| 'hover-bg'
|
||||
| 'border'
|
||||
| 'text'
|
||||
| 'text-muted'
|
||||
| 'accent'
|
||||
| 'accent-dark'
|
||||
| 'accent-glow'
|
||||
| 'accent-gradient'
|
||||
| 'header-bg';
|
||||
const matomoContainerSrc = 'https://matomo.howdoyouconvert.com/js/container_B3r877Kn.js';
|
||||
|
||||
type PaletteTheme = Record<PaletteVar, string>;
|
||||
|
||||
type Palette = {
|
||||
slug: string;
|
||||
label: string;
|
||||
light: PaletteTheme;
|
||||
dark: PaletteTheme;
|
||||
type WindowWithAnalytics = Window & {
|
||||
_mtm?: Array<Record<string, unknown>>;
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
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)',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let sidebarOpen = false;
|
||||
let headerSearchOpen = false;
|
||||
let isMobileHeader = false;
|
||||
let theme: ThemeMode = 'dark';
|
||||
let selectedPaletteIndex = 0;
|
||||
let savedScrollRestoration: ScrollRestoration | null = null;
|
||||
$: isHomepage = $page.url.pathname === '/';
|
||||
$: if (isHomepage && sidebarOpen) {
|
||||
$: if (isHomepage && (sidebarOpen || headerSearchOpen)) {
|
||||
sidebarOpen = false;
|
||||
headerSearchOpen = false;
|
||||
}
|
||||
$: if (!isMobileHeader && headerSearchOpen) {
|
||||
headerSearchOpen = false;
|
||||
}
|
||||
|
||||
const applyPalette = (index: number, persist = false) => {
|
||||
@@ -289,8 +61,55 @@
|
||||
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(() => {
|
||||
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 savedPalette = window.localStorage.getItem('palette');
|
||||
@@ -313,10 +132,35 @@
|
||||
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 = () => {
|
||||
if ('removeEventListener' in mediaQuery) {
|
||||
@@ -329,6 +173,21 @@
|
||||
} 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) {
|
||||
@@ -342,6 +201,11 @@
|
||||
} else {
|
||||
navBreakpoint.addListener(handleNavBreakpoint);
|
||||
}
|
||||
if ('addEventListener' in headerBreakpoint) {
|
||||
headerBreakpoint.addEventListener('change', handleHeaderBreakpoint);
|
||||
} else {
|
||||
headerBreakpoint.addListener(handleHeaderBreakpoint);
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
@@ -349,26 +213,23 @@
|
||||
|
||||
<svelte:head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<!-- 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 -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
|
||||
</svelte:head>
|
||||
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div class="header-left">
|
||||
{#if !isHomepage}
|
||||
<button
|
||||
type="button"
|
||||
class="hamburger"
|
||||
on:click={() => (sidebarOpen = !sidebarOpen)}
|
||||
on:click={() => {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
if (sidebarOpen) {
|
||||
headerSearchOpen = false;
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle menu"
|
||||
aria-controls="site-navigation"
|
||||
aria-expanded={sidebarOpen ? 'true' : 'false'}
|
||||
@@ -377,19 +238,39 @@
|
||||
</button>
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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">
|
||||
{#if !isHomepage}
|
||||
<Sidebar bind:open={sidebarOpen} />
|
||||
{/if}
|
||||
<main class="main-content">
|
||||
<main id="main-content" class="main-content" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,72 @@
|
||||
<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 SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import { buildSeoMeta, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
|
||||
const cats = getCategoriesWithCounts();
|
||||
const totalCalculators = calculators.length;
|
||||
const totalCategories = cats.length;
|
||||
const requiredCategoryFallbacks: Record<string, { label: string; icon: string }> = {
|
||||
fluids: { label: 'Fluids', icon: '💧' },
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>HowDoYouConvert.com — Free Unit Conversion Calculators</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." />
|
||||
<title>{pageTitle}</title>
|
||||
<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>
|
||||
|
||||
<section class="hero">
|
||||
<h1>How Do You Convert?</h1>
|
||||
<p>Fast, bidirectional unit conversions with no ads.</p>
|
||||
<p>Fast unit conversions with no ads.</p>
|
||||
<div class="search-center">
|
||||
<SearchBar />
|
||||
<SearchBar idPrefix="home-search" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<div class="stat-num">{totalCalculators}</div>
|
||||
<div class="stat-num">{totalConversions}</div>
|
||||
<div class="stat-label">Converters</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num">{totalCategories}</div>
|
||||
<div class="stat-num">{totalCategoriesCount}</div>
|
||||
<div class="stat-label">Categories</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
3
hdyc-svelte/src/routes/+page.ts
Normal file
3
hdyc-svelte/src/routes/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Prerender the homepage as static HTML at build time.
|
||||
// adapter-node will serve this as a static file — no SSR round-trip.
|
||||
export const prerender = true;
|
||||
@@ -1,38 +1,90 @@
|
||||
<script lang="ts">
|
||||
import Calculator from '$lib/components/Calculator.svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
$: calc = data.calculator;
|
||||
$: 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,
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
window.scrollTo({ top: 0 });
|
||||
return afterNavigate(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{calc.name} — HowDoYouConvert.com</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." />
|
||||
<title>{pageTitle}</title>
|
||||
<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>
|
||||
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a>
|
||||
<span class="sep">›</span>
|
||||
<a href="/category/{calc.category}">{data.categoryIcon} {data.categoryLabel}</a>
|
||||
<span class="sep">›</span>
|
||||
<span>{calc.name}</span>
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/category/{calc.category}">{data.categoryIcon} {data.categoryLabel}</a></li>
|
||||
<li aria-current="page">{calc.name}</li>
|
||||
</ol>
|
||||
</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">
|
||||
{#if calc.descriptionHTML}
|
||||
|
||||
@@ -1,18 +1,102 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
import { getConversionRateText } from '$lib/utils/conversionRate';
|
||||
|
||||
const handleCalcTooltipMousemove = (event: MouseEvent) => {
|
||||
const card = event.currentTarget as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = Math.min(Math.max(event.clientX - rect.left, 0), rect.width);
|
||||
const y = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
|
||||
card.style.setProperty('--calc-tooltip-left', `${x}px`);
|
||||
card.style.setProperty('--calc-tooltip-top', `${y}px`);
|
||||
card.style.setProperty('--calc-tooltip-bottom', 'auto');
|
||||
card.style.setProperty('--calc-tooltip-translate', 'calc(-100% - 0.55rem)');
|
||||
};
|
||||
|
||||
const resetCalcTooltipPosition = (event: MouseEvent) => {
|
||||
const card = event.currentTarget as HTMLElement | null;
|
||||
if (!card) return;
|
||||
card.style.removeProperty('--calc-tooltip-left');
|
||||
card.style.removeProperty('--calc-tooltip-top');
|
||||
card.style.removeProperty('--calc-tooltip-bottom');
|
||||
card.style.removeProperty('--calc-tooltip-translate');
|
||||
};
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
$: 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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.label} Converters — HowDoYouConvert.com</title>
|
||||
<meta name="description" content="Browse all {data.label.toLowerCase()} unit converters. Free online calculators for converting between {data.label.toLowerCase()} units." />
|
||||
<title>{pageTitle}</title>
|
||||
<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>
|
||||
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a>
|
||||
<span class="sep">›</span>
|
||||
<span>{data.icon} {data.label}</span>
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li aria-current="page">{data.icon} {data.label}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="page-title">{data.icon} {data.label} Converters</h1>
|
||||
@@ -23,8 +107,18 @@
|
||||
|
||||
<div class="calc-list">
|
||||
{#each data.calculators as calc}
|
||||
<a href="/{calc.slug}" class="calc-list-item">
|
||||
{@const conversionRateText = getConversionRateText(calc)}
|
||||
<a
|
||||
href="/{calc.slug}"
|
||||
class="calc-list-item"
|
||||
on:mousemove={handleCalcTooltipMousemove}
|
||||
on:mouseleave={resetCalcTooltipPosition}
|
||||
on:focus={resetCalcTooltipPosition}
|
||||
>
|
||||
{calc.name}
|
||||
{#if conversionRateText}
|
||||
<span class="calc-list-tooltip" role="tooltip">{conversionRateText}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { calculators, categories } from '$lib/data/calculators';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const calculatorUrls = calculators.map(
|
||||
(calc) => `
|
||||
<url>
|
||||
(calc) => ` <url>
|
||||
<loc>https://howdoyouconvert.com/${calc.slug}</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
@@ -12,8 +11,7 @@ export const GET: RequestHandler = async () => {
|
||||
);
|
||||
|
||||
const categoryUrls = Object.keys(categories).map(
|
||||
(category) => `
|
||||
<url>
|
||||
(category) => ` <url>
|
||||
<loc>https://howdoyouconvert.com/category/${category}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
@@ -27,8 +25,8 @@ export const GET: RequestHandler = async () => {
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
${categoryUrls.join('')}
|
||||
${calculatorUrls.join('')}
|
||||
${categoryUrls.join('\n')}
|
||||
${calculatorUrls.join('\n')}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(sitemap, {
|
||||
|
||||
37133
hdyc-svelte/static/data/calculators.json
Normal file
37133
hdyc-svelte/static/data/calculators.json
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Sitemap: https://howdoyouconvert.com/sitemap.xml
|
||||
|
||||
269
migrate.py
269
migrate.py
@@ -1,10 +1,117 @@
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
CALCLIST = BASE_DIR / 'calculators_list.md'
|
||||
OUTPUT_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/calculators.ts'
|
||||
STATS_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/stats.ts'
|
||||
CALCULATORS_JSON = BASE_DIR / 'hdyc-svelte/static/data/calculators.json'
|
||||
|
||||
CATEGORY_KEYS = [
|
||||
'length',
|
||||
'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():
|
||||
# Placeholder for future enrichment sources.
|
||||
@@ -31,12 +138,14 @@ def parse_calculators_list():
|
||||
parts = [p.strip() for p in line.strip().strip('|').split('|')]
|
||||
name_idx = header_map.get('Calculator Name')
|
||||
slug_idx = header_map.get('Slug')
|
||||
category_idx = header_map.get('Category')
|
||||
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]
|
||||
slug = parts[slug_idx]
|
||||
category = parts[category_idx]
|
||||
factor_raw = parts[factor_idx]
|
||||
active_calcs.append((name, slug, factor_raw))
|
||||
active_calcs.append((name, slug, category, factor_raw))
|
||||
|
||||
return active_calcs
|
||||
|
||||
@@ -53,32 +162,10 @@ def split_conversion_name(name):
|
||||
return parts[0].strip(), parts[1].strip()
|
||||
return None
|
||||
|
||||
def guess_category(name):
|
||||
name_l = name.lower()
|
||||
if any(x in name_l for x in ['acre-foot', 'acre-feet', 'acrefoot', 'acre feet']):
|
||||
return 'volume'
|
||||
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 ['degree', 'degrees', 'radian', 'radians', 'arcminute', 'arcminutes', 'arcsecond', 'arcseconds', 'gradian', 'gradians', 'mil', 'mils', 'quadrant', 'quadrants', 'sextant', 'sextants', 'turn', 'turns', 'points (compass', 'points-compass']): return 'angle'
|
||||
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 ['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'
|
||||
if any(x in name_l for x in ['binary', 'hex', 'octal', 'decimal', 'base']):
|
||||
return 'number-systems'
|
||||
return 'other'
|
||||
def normalize_category(raw: str) -> str:
|
||||
normalized = raw.strip().lower().replace(' ', '-')
|
||||
normalized = re.sub(r'[^a-z0-9-]', '', normalized)
|
||||
return normalized
|
||||
|
||||
def process():
|
||||
external_descriptions = load_external_descriptions()
|
||||
@@ -86,7 +173,9 @@ def process():
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -99,14 +188,55 @@ def process():
|
||||
else:
|
||||
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, "")
|
||||
|
||||
c_type = 'standard'
|
||||
factor_val = "1"
|
||||
offset_val = "0"
|
||||
|
||||
if '1/x' in factor_raw:
|
||||
# Special-case calculator families that require custom math beyond simple factors.
|
||||
if 'molarity-to-grams-per-liter' == slug:
|
||||
c_type = 'molarity'
|
||||
custom_labels = {'in1': 'Molarity (mol/L)', 'in2': 'Grams per liter', 'in3': 'Molar mass (g/mol)'}
|
||||
elif 'rockwell-c-to-vickers' == slug:
|
||||
c_type = 'rockwell-vickers'
|
||||
elif 'ev-to-lux' in slug or 'lux-to-ev' in slug:
|
||||
c_type = 'ev-lux'
|
||||
elif 'focal-length-to-angle-of-view' in slug:
|
||||
c_type = 'aov'
|
||||
elif 'awg' in slug:
|
||||
c_type = 'awg'
|
||||
elif 'swg-to' in slug or '-to-swg' in slug:
|
||||
c_type = 'swg'
|
||||
elif 'brinell-to-rockwell-c' == slug or 'rockwell-c-to-brinell' == slug:
|
||||
c_type = 'brinell-rockwell'
|
||||
elif 'saybolt-universal-seconds-to-centistokes' == slug:
|
||||
c_type = 'sus-cst'
|
||||
elif '1/x' in factor_raw:
|
||||
c_type = 'inverse'
|
||||
factor_val = "1"
|
||||
elif 'Multi-Variable' in factor_raw:
|
||||
@@ -154,6 +284,11 @@ def process():
|
||||
except:
|
||||
pass
|
||||
|
||||
# Give 3-col calculators honest display names instead of "A to B"
|
||||
if c_type in ['3col', '3col-mul'] and split_conversion_name(display_name):
|
||||
op = '*' if c_type == '3col-mul' else '/'
|
||||
display_name = f"{in1} {op} {in2}"
|
||||
|
||||
# Avoid escaping single quotes by using JSON or dict
|
||||
entry = {
|
||||
'slug': slug,
|
||||
@@ -167,11 +302,19 @@ def process():
|
||||
# Determine labels
|
||||
labels = {'in1': in1, 'in2': in2}
|
||||
if c_type in ['3col', '3col-mul']:
|
||||
# generic 3rd label
|
||||
if 'watts' in slug and 'amps' in slug: labels['in3'] = 'Volts'
|
||||
elif 'lumens' in slug: labels['in3'] = 'Area (sq m)'
|
||||
elif 'moles' in slug: labels['in3'] = 'Molar Mass'
|
||||
else: labels['in3'] = 'Result'
|
||||
# generic 3rd label; make it descriptive instead of the vague "Result"
|
||||
if 'watts' in slug and 'amps' in slug:
|
||||
labels['in3'] = 'Volts'
|
||||
elif 'lumens' in slug:
|
||||
labels['in3'] = 'Area (sq m)'
|
||||
elif 'moles' in slug:
|
||||
labels['in3'] = 'Molar Mass'
|
||||
else:
|
||||
op = '*' if c_type == '3col-mul' else '/'
|
||||
labels['in3'] = f"{in1} {op} {in2}"
|
||||
|
||||
if custom_labels:
|
||||
labels = custom_labels
|
||||
|
||||
entry['labels'] = labels
|
||||
|
||||
@@ -227,7 +370,7 @@ def process():
|
||||
def get_val(k):
|
||||
if k in units_10: return 10, units_10[k]
|
||||
if k in units_2: return 2, units_2[k]
|
||||
if k == 'bit': return 10, -1 # placeholder relative to bytes, though bits are 1/8 byte. Handling simple bytes here only
|
||||
if k == 'bit': return 2, -3 # bit is 1/8 of a byte (2^-3)
|
||||
return None, None
|
||||
|
||||
b1, e1 = get_val(in1_key)
|
||||
@@ -265,6 +408,8 @@ def process():
|
||||
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.
|
||||
# But a better heuristic: reverse of split(' to ')
|
||||
if e.get('category') == 'data':
|
||||
continue
|
||||
parsed = split_conversion_name(e['name'])
|
||||
if parsed:
|
||||
rev_name = f"{parsed[1]} to {parsed[0]}"
|
||||
@@ -281,7 +426,7 @@ def process():
|
||||
# Ensure types are right
|
||||
# write to TS
|
||||
out = """// THIS FILE IS AUTO-GENERATED BY migrate.py
|
||||
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w';
|
||||
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w' | 'awg' | 'brinell-rockwell' | 'ev-lux' | 'aov' | 'swg' | 'rockwell-vickers' | 'sus-cst' | 'molarity';
|
||||
|
||||
export interface CalculatorDef {
|
||||
slug: string;
|
||||
@@ -299,26 +444,12 @@ export interface CalculatorDef {
|
||||
}
|
||||
|
||||
export const categories: Record<string, { label: string; icon: string }> = {
|
||||
length: { label: 'Length / Distance', icon: '📏' },
|
||||
weight: { label: 'Weight / Mass', icon: '⚖️' },
|
||||
temperature: { label: 'Temperature', icon: '🌡️' },
|
||||
volume: { label: 'Volume', icon: '🧪' },
|
||||
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: '🔄' },
|
||||
};
|
||||
"""
|
||||
for k, v in CATEGORIES.items():
|
||||
out += f" '{k}': {json.dumps(v, ensure_ascii=False).replace('{', '{ ').replace('}', ' }')},\n"
|
||||
out += "};\n"
|
||||
|
||||
out += """
|
||||
export const calculators: CalculatorDef[] = [
|
||||
"""
|
||||
for e in calculators_ts_entries:
|
||||
@@ -333,8 +464,13 @@ export const calculators: CalculatorDef[] = [
|
||||
out += """
|
||||
];
|
||||
|
||||
const slugIndex = new Map(calculators.map(c => [c.slug, c]));
|
||||
const slugIndex: Map<string, CalculatorDef> = new Map(
|
||||
calculators.map(calc => [calc.slug, calc])
|
||||
);
|
||||
|
||||
"""
|
||||
|
||||
out += """
|
||||
export function getCalculatorBySlug(slug: string): CalculatorDef | undefined {
|
||||
return slugIndex.get(slug);
|
||||
}
|
||||
@@ -366,5 +502,22 @@ export function searchCalculators(query: string): CalculatorDef[] {
|
||||
|
||||
print(f"Generated {len(calculators_ts_entries)} calculators into calculators.ts")
|
||||
|
||||
# Generate stats.ts
|
||||
total_count = len(calculators_ts_entries)
|
||||
stats_content = f"""// THIS FILE IS AUTO-GENERATED BY migrate.py
|
||||
export const categories: Record<string, {{ label: string; icon: string }}> = {json.dumps(CATEGORIES, indent=2, ensure_ascii=False)};
|
||||
|
||||
export const totalCalculators = {total_count};
|
||||
"""
|
||||
with open(STATS_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(stats_content)
|
||||
print(f"Generated stats.ts with {total_count} total calculators")
|
||||
|
||||
# Generate calculators.json for true lazy loading
|
||||
os.makedirs(os.path.dirname(CALCULATORS_JSON), exist_ok=True)
|
||||
with open(CALCULATORS_JSON, 'w', encoding='utf-8') as f:
|
||||
json.dump(calculators_ts_entries, f, ensure_ascii=False, indent=2)
|
||||
print(f"Generated calculators.json (Size: {os.path.getsize(CALCULATORS_JSON) // 1024}KB)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
process()
|
||||
|
||||
27
tests/test_apothecary_page.py
Normal file
27
tests/test_apothecary_page.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import unittest
|
||||
|
||||
URL = 'https://howdoyouconvert.com/apothecary-ounces-to-amu'
|
||||
|
||||
|
||||
class ApothecaryPageTests(unittest.TestCase):
|
||||
def test_apothecary_page_returns_success(self) -> None:
|
||||
"""The published URL should return a 200 so the calculator page stays healthy."""
|
||||
request = urllib.request.Request(
|
||||
URL,
|
||||
headers={
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
status = response.getcode()
|
||||
except urllib.error.HTTPError as exc:
|
||||
self.fail(f'{URL} returned HTTP {exc.code} ({exc.reason})')
|
||||
except urllib.error.URLError as exc:
|
||||
self.fail(f'{URL} could not be fetched: {exc}')
|
||||
|
||||
self.assertEqual(status, 200, f'{URL} returned {status}')
|
||||
106
tests/test_consistency.py
Normal file
106
tests/test_consistency.py
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import math
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||
|
||||
def _js_fmt(n: float) -> str:
|
||||
"""Mimics the fmt() function in engine.ts"""
|
||||
if not math.isfinite(n):
|
||||
return str(n)
|
||||
if n == 0:
|
||||
return "0"
|
||||
if abs(n) < 1e-6:
|
||||
return f"{n:.6e}".replace("e-0", "e-").replace("e+0", "e+")
|
||||
|
||||
# engine.ts uses parseFloat(n.toFixed(6)).toString()
|
||||
rounded = round(n, 6)
|
||||
if rounded == 0: # Handle -0.0
|
||||
return "0"
|
||||
if rounded == int(rounded):
|
||||
return str(int(rounded))
|
||||
return str(rounded)
|
||||
|
||||
def _js_fmt_precise(n: float) -> str:
|
||||
"""Mimics formatExampleValue in QuickConversionExample.svelte"""
|
||||
if n is None or math.isnan(n):
|
||||
return "—"
|
||||
if not math.isfinite(n):
|
||||
return str(n)
|
||||
if n == 0:
|
||||
return "0"
|
||||
|
||||
rounded = round(n, 6)
|
||||
if rounded != 0:
|
||||
if rounded == int(rounded):
|
||||
return str(int(rounded))
|
||||
return str(rounded)
|
||||
|
||||
# Precise version for very small numbers
|
||||
precise = f"{n:.12f}".rstrip('0').rstrip('.')
|
||||
return precise if precise else "0"
|
||||
|
||||
class TestCalculatorsConsistency(unittest.TestCase):
|
||||
def test_standard_calculators_consistency(self):
|
||||
text = CALCULATORS_TS.read_text(encoding="utf-8")
|
||||
|
||||
# Extract the calculators array content
|
||||
match = re.search(r"export const calculators: CalculatorDef\[\] = \[(.*?)\];", text, re.S)
|
||||
self.assertTrue(match, "Could not find calculators array in calculators.ts")
|
||||
|
||||
body = match.group(1)
|
||||
# Split by '{"slug":' to avoid splitting on nested braces
|
||||
raw_entries = body.split('{"slug":')
|
||||
|
||||
errors = []
|
||||
for raw_entry in raw_entries:
|
||||
if not raw_entry.strip():
|
||||
continue
|
||||
entry = '{"slug":' + raw_entry
|
||||
slug_match = re.search(r'"slug": "(.*?)"', entry)
|
||||
if not slug_match:
|
||||
continue
|
||||
slug = slug_match.group(1)
|
||||
|
||||
type_match = re.search(r'"type": "(.*?)"', entry)
|
||||
if not type_match or type_match.group(1) != "standard":
|
||||
continue
|
||||
|
||||
# Use non-greedy search for factor/offset and handle potential whitespace
|
||||
factor_match = re.search(r'"factor":\s*([0-9.eE+-]+)', entry)
|
||||
offset_match = re.search(r'"offset":\s*([0-9.eE+-]+)', entry)
|
||||
|
||||
factor = float(factor_match.group(1)) if factor_match else 1.0
|
||||
offset = float(offset_match.group(1)) if offset_match else 0.0
|
||||
|
||||
# 1. Formula Hint vs Chart Row 1 Consistency
|
||||
# Logic: solve(config, 1, "1") -> val2 = fmt(1 * factor + offset)
|
||||
row_one_output = _js_fmt(1.0 * factor + offset)
|
||||
|
||||
# 2. How to convert Examples Consistency (Inverse)
|
||||
if factor != 0:
|
||||
reverse_val = (1.0 - offset) / factor
|
||||
formatted_reverse = _js_fmt_precise(reverse_val)
|
||||
|
||||
# Specific check for Réaumur to Kelvin (User request)
|
||||
if slug == "reaumur-to-kelvin":
|
||||
if formatted_reverse != "-217.72":
|
||||
errors.append(f"[{slug}] Reverse example mismatch: expected -217.72, got {formatted_reverse}")
|
||||
if row_one_output != "274.4":
|
||||
errors.append(f"[{slug}] Chart row 1 mismatch: expected 274.4, got {row_one_output}")
|
||||
|
||||
# Specific check for Feet per minute to Knots (Previous bug fix)
|
||||
if slug == "feet-per-minute-to-knots":
|
||||
if row_one_output != "0.009875":
|
||||
errors.append(f"[{slug}] Chart row 1 mismatch: expected 0.009875, got {row_one_output}")
|
||||
if formatted_reverse != "101.268504":
|
||||
errors.append(f"[{slug}] Reverse example mismatch: expected 101.268504, got {formatted_reverse}")
|
||||
|
||||
if errors:
|
||||
self.fail("\n" + "\n".join(errors))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
46
tests/test_conversion_chart.py
Normal file
46
tests/test_conversion_chart.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import math
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||
NUMERIC_SAMPLES = [0.1, 0.5, 1, 2, 5, 10, 20, 50, 100]
|
||||
TARGET_SLUG = "angstroms-to-nautical-miles"
|
||||
|
||||
|
||||
def _parse_factor(slug: str) -> float:
|
||||
text = CALCULATORS_TS.read_text(encoding="utf-8")
|
||||
match = re.search(rf'"slug": "{slug}".*?"factor": ([0-9.eE+-]+)', text, flags=re.S)
|
||||
if not match:
|
||||
raise AssertionError(f"Could not find calculator definition for '{slug}'")
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def _js_fmt(value: float) -> str:
|
||||
if not math.isfinite(value):
|
||||
return str(value)
|
||||
if value == 0:
|
||||
return "0"
|
||||
abs_val = abs(value)
|
||||
if abs_val < 1e-6:
|
||||
return f"{value:.6e}"
|
||||
rounded = float(f"{value:.6f}")
|
||||
if rounded.is_integer():
|
||||
return str(int(rounded))
|
||||
return repr(rounded)
|
||||
|
||||
|
||||
class QuickConversionChartRoundingTests(unittest.TestCase):
|
||||
def test_angstroms_to_nautical_miles_chart_does_not_round_to_zero(self) -> None:
|
||||
factor = _parse_factor(TARGET_SLUG)
|
||||
outputs = [_js_fmt(sample * factor) for sample in NUMERIC_SAMPLES]
|
||||
self.assertTrue(
|
||||
any(output != "0" for output in outputs),
|
||||
f"{TARGET_SLUG} quick conversion chart rounded every sample down to 0: {outputs}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
27
tests/test_conversion_rate_tooltip.py
Normal file
27
tests/test_conversion_rate_tooltip.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONVERSION_RATE = (
|
||||
ROOT / "hdyc-svelte" / "src" / "lib" / "utils" / "conversionRate.ts"
|
||||
)
|
||||
|
||||
|
||||
class ConversionRateTooltipFormattingTests(unittest.TestCase):
|
||||
def test_conversion_rate_text_uses_formatter(self) -> None:
|
||||
text = CONVERSION_RATE.read_text(encoding="utf-8")
|
||||
normalized = " ".join(text.split())
|
||||
self.assertIn(
|
||||
"formatConversionValue(config.factor)",
|
||||
normalized,
|
||||
"Conversion rate helper must format the factor before inserting it into the tooltip text",
|
||||
)
|
||||
self.assertIn(
|
||||
"formatConversionValue(config.offset ?? 0)",
|
||||
normalized,
|
||||
"Conversion rate tooltip must format any offset before rendering",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
68
tests/test_gauss_to_oersted_conversion_rate.py
Normal file
68
tests/test_gauss_to_oersted_conversion_rate.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
|
||||
CONVERSION_RATE_UTIL = ROOT / "hdyc-svelte" / "src" / "lib" / "utils" / "conversionRate.ts"
|
||||
CATEGORY_PAGE = ROOT / "hdyc-svelte" / "src" / "routes" / "category" / "[category]" / "+page.svelte"
|
||||
CALCULATOR_COMPONENT = ROOT / "hdyc-svelte" / "src" / "lib" / "components" / "Calculator.svelte"
|
||||
TARGET_SLUG = "gauss-to-oersted"
|
||||
NON_APPLICABLE_TWO_INPUT_STANDARD_SLUGS = {
|
||||
"grams-per-liter-to-molarity",
|
||||
}
|
||||
|
||||
|
||||
def _extract_calculator_block(slug: str) -> str:
|
||||
for line in CALCULATORS_TS.read_text(encoding="utf-8").splitlines():
|
||||
if f'"slug": "{slug}"' in line:
|
||||
return line
|
||||
raise AssertionError(f"Could not find calculator definition for '{slug}'")
|
||||
|
||||
|
||||
class GaussToOerstedConversionRateRegressionTests(unittest.TestCase):
|
||||
def test_gauss_to_oersted_includes_factor_for_conversion_rate_and_tooltip(self) -> None:
|
||||
block = _extract_calculator_block(TARGET_SLUG)
|
||||
self.assertRegex(
|
||||
block,
|
||||
r'"factor":\s*[0-9.eE+-]+',
|
||||
"Missing factor on gauss-to-oersted prevents conversion rate text from rendering in both calculator footer and category tooltip.",
|
||||
)
|
||||
|
||||
def test_conversion_rate_is_wired_to_both_surfaces(self) -> None:
|
||||
util_text = CONVERSION_RATE_UTIL.read_text(encoding="utf-8")
|
||||
category_page_text = CATEGORY_PAGE.read_text(encoding="utf-8")
|
||||
calculator_component_text = CALCULATOR_COMPONENT.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("getConversionRateText", util_text)
|
||||
self.assertIn("conversionRateText = getConversionRateText(calc)", category_page_text)
|
||||
self.assertIn('role="tooltip"', category_page_text)
|
||||
self.assertIn("conversionRateText = getConversionRateText(config)", calculator_component_text)
|
||||
self.assertIn('<span class="formula-hint">', calculator_component_text)
|
||||
|
||||
def test_all_applicable_two_input_standard_calculators_have_factors(self) -> None:
|
||||
missing_factors: list[str] = []
|
||||
for line in CALCULATORS_TS.read_text(encoding="utf-8").splitlines():
|
||||
if '"type": "standard"' not in line:
|
||||
continue
|
||||
if '"labels": {"in1":' not in line or '"in2":' not in line or '"in3":' in line:
|
||||
continue
|
||||
slug_match = re.search(r'"slug": "([^"]+)"', line)
|
||||
if not slug_match:
|
||||
continue
|
||||
slug = slug_match.group(1)
|
||||
if slug in NON_APPLICABLE_TWO_INPUT_STANDARD_SLUGS:
|
||||
continue
|
||||
if '"factor":' not in line:
|
||||
missing_factors.append(slug)
|
||||
|
||||
self.assertEqual(
|
||||
missing_factors,
|
||||
[],
|
||||
f"Two-input standard calculators missing factors (and therefore conversion-rate text): {missing_factors}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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