Added aspect ratio calculator

This commit is contained in:
Chris Watson 2024-08-14 15:42:23 -06:00
parent f1a5489e21
commit 45e8f98eb4
6 changed files with 331 additions and 1 deletions

34
components.d.ts vendored
View file

@ -13,6 +13,7 @@ declare module '@vue/runtime-core' {
About: typeof import('./src/pages/About.vue')['default'] About: typeof import('./src/pages/About.vue')['default']
App: typeof import('./src/App.vue')['default'] App: typeof import('./src/App.vue')['default']
AsciiTextDrawer: typeof import('./src/tools/ascii-text-drawer/ascii-text-drawer.vue')['default'] AsciiTextDrawer: typeof import('./src/tools/ascii-text-drawer/ascii-text-drawer.vue')['default']
AspectRatioCalculator: typeof import('./src/tools/aspect-ratio-calculator/aspect-ratio-calculator.vue')['default']
'Base.layout': typeof import('./src/layouts/base.layout.vue')['default'] 'Base.layout': typeof import('./src/layouts/base.layout.vue')['default']
Base64FileConverter: typeof import('./src/tools/base64-file-converter/base64-file-converter.vue')['default'] Base64FileConverter: typeof import('./src/tools/base64-file-converter/base64-file-converter.vue')['default']
Base64StringConverter: typeof import('./src/tools/base64-string-converter/base64-string-converter.vue')['default'] Base64StringConverter: typeof import('./src/tools/base64-string-converter/base64-string-converter.vue')['default']
@ -89,17 +90,28 @@ declare module '@vue/runtime-core' {
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default'] HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default']
'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] 'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
IconMdiCamera: typeof import('~icons/mdi/camera')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default'] IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default'] IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default'] IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default'] IconMdiHeart: typeof import('~icons/mdi/heart')['default']
IconMdiPause: typeof import('~icons/mdi/pause')['default']
IconMdiPlay: typeof import('~icons/mdi/play')['default']
IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default'] IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiTranslate: typeof import('~icons/mdi/translate')['default'] IconMdiTranslate: typeof import('~icons/mdi/translate')['default']
IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default'] IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default'] Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
@ -127,18 +139,40 @@ declare module '@vue/runtime-core' {
MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] MenuLayout: typeof import('./src/components/MenuLayout.vue')['default']
MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default']
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCode: typeof import('naive-ui')['NCode'] NCode: typeof import('naive-ui')['NCode']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDivider: typeof import('naive-ui')['NDivider']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis'] NEllipsis: typeof import('naive-ui')['NEllipsis']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1'] NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3'] NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout'] NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu'] NMenu: typeof import('naive-ui')['NMenu']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable']
NTag: typeof import('naive-ui')['NTag']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']

View file

@ -0,0 +1,71 @@
// aspect-ratio-calculator.service.test.ts
import { describe, expect, it } from 'vitest';
import {
type AspectRatio,
type Dimensions,
calculateAspectRatio,
calculateDimensions,
simplifyRatio,
} from './aspect-ratio-calculator.service';
describe('Aspect Ratio Calculator Service', () => {
describe('calculateAspectRatio', () => {
it('calculates correct aspect ratio for 1920x1080', () => {
const result = calculateAspectRatio(1920, 1080);
expect(result).toEqual({ r1: 16, r2: 9 });
});
it('calculates correct aspect ratio for 640x480', () => {
const result = calculateAspectRatio(640, 480);
expect(result).toEqual({ r1: 4, r2: 3 });
});
it('handles square aspect ratio', () => {
const result = calculateAspectRatio(1000, 1000);
expect(result).toEqual({ r1: 1, r2: 1 });
});
});
describe('calculateDimensions', () => {
it('calculates correct height given width and 16:9 ratio', () => {
const ratio: AspectRatio = { r1: 16, r2: 9 };
const result = calculateDimensions(1920, ratio, true);
expect(result).toEqual({ width: 1920, height: 1080 });
});
it('calculates correct width given height and 4:3 ratio', () => {
const ratio: AspectRatio = { r1: 4, r2: 3 };
const result = calculateDimensions(480, ratio, false);
expect(result).toEqual({ width: 640, height: 480 });
});
it('handles 1:1 ratio', () => {
const ratio: AspectRatio = { r1: 1, r2: 1 };
const result = calculateDimensions(500, ratio, true);
expect(result).toEqual({ width: 500, height: 500 });
});
});
describe('simplifyRatio', () => {
it('simplifies 16:9 ratio', () => {
const result = simplifyRatio(16, 9);
expect(result).toEqual({ r1: 16, r2: 9 });
});
it('simplifies 1920:1080 to 16:9', () => {
const result = simplifyRatio(1920, 1080);
expect(result).toEqual({ r1: 16, r2: 9 });
});
it('simplifies 4:2 to 2:1', () => {
const result = simplifyRatio(4, 2);
expect(result).toEqual({ r1: 2, r2: 1 });
});
it('handles already simplified ratios', () => {
const result = simplifyRatio(7, 5);
expect(result).toEqual({ r1: 7, r2: 5 });
});
});
});

View file

@ -0,0 +1,44 @@
// aspect-ratio-calculator.service.ts
export interface AspectRatio {
r1: number
r2: number
}
export interface Dimensions {
width: number
height: number
}
export function calculateAspectRatio(width: number, height: number): AspectRatio {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
const divisor = gcd(width, height);
return {
r1: width / divisor,
r2: height / divisor,
};
}
export function calculateDimensions(
knownDimension: number,
ratio: AspectRatio,
isWidth: boolean,
): Dimensions {
if (isWidth) {
const height = Math.round((knownDimension * ratio.r2) / ratio.r1);
return { width: knownDimension, height };
}
else {
const width = Math.round((knownDimension * ratio.r1) / ratio.r2);
return { width, height: knownDimension };
}
}
export function simplifyRatio(r1: number, r2: number): AspectRatio {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
const divisor = gcd(r1, r2);
return {
r1: r1 / divisor,
r2: r2 / divisor,
};
}

View file

@ -0,0 +1,168 @@
<!-- AspectRatioCalculator.vue -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { NDivider, NInputNumber, NSelect, NSpace } from 'naive-ui';
import {
type AspectRatio,
calculateAspectRatio,
calculateDimensions,
simplifyRatio,
} from './aspect-ratio-calculator.service';
const width = ref<number | null>(null);
const height = ref<number | null>(null);
const r1 = ref<number | null>(null);
const r2 = ref<number | null>(null);
const presets = [
{ label: 'HD Video 16:9', value: '16:9' },
{ label: 'SD Video 4:3', value: '4:3' },
{ label: 'Widescreen 21:9', value: '21:9' },
{ label: 'Square 1:1', value: '1:1' },
];
const selectedPreset = ref(null);
const aspectRatio = computed((): AspectRatio | null => {
if (r1.value && r2.value) {
return simplifyRatio(r1.value, r2.value);
}
return null;
});
function updateDimensions(changedField: 'width' | 'height') {
if (aspectRatio.value) {
if (changedField === 'width' && width.value) {
const newDimensions = calculateDimensions(width.value, aspectRatio.value, true);
height.value = newDimensions.height;
}
else if (changedField === 'height' && height.value) {
const newDimensions = calculateDimensions(height.value, aspectRatio.value, false);
width.value = newDimensions.width;
}
}
}
function handlePresetChange(value: string) {
const [newR1, newR2] = value.split(':').map(Number);
r1.value = newR1;
r2.value = newR2;
if (width.value) {
updateDimensions('width');
}
else if (height.value) {
updateDimensions('height');
}
}
watch([r1, r2], () => {
if (r1.value && r2.value) {
if (width.value) {
updateDimensions('width');
}
else if (height.value) {
updateDimensions('height');
}
}
});
</script>
<template>
<NSpace vertical :size="24">
<div>
<h3>Common Presets</h3>
<NSelect
v-model:value="selectedPreset"
:options="presets"
placeholder="Select a preset"
@update:value="handlePresetChange"
/>
</div>
<div class="input-group">
<div class="input-pair">
<label>Pixels width</label>
<NInputNumber v-model:value="width" placeholder="Pixels width" :min="1" @update:value="() => updateDimensions('width')" />
</div>
<div class="input-pair">
<label>Pixels height</label>
<NInputNumber v-model:value="height" placeholder="Pixels height" :min="1" @update:value="() => updateDimensions('height')" />
</div>
</div>
<div class="input-group">
<div class="input-pair">
<label>Ratio width</label>
<NInputNumber v-model:value="r1" placeholder="Ratio width" :min="1" />
</div>
<div class="separator">
:
</div>
<div class="input-pair">
<label>Ratio height</label>
<NInputNumber v-model:value="r2" placeholder="Ratio height" :min="1" />
</div>
</div>
</NSpace>
</template>
<style scoped>
h2 {
font-size: 24px;
margin-bottom: 8px;
}
h3 {
font-size: 18px;
margin-bottom: 8px;
}
p {
margin-bottom: 24px;
color: #a0a0a0;
}
.input-group {
display: flex;
align-items: flex-end;
gap: 16px;
}
.input-pair {
flex: 1;
display: flex;
flex-direction: column;
}
.input-pair label {
margin-bottom: 4px;
color: #a0a0a0;
}
.separator {
align-self: flex-end;
margin-bottom: 7px;
font-size: 24px;
font-weight: bold;
}
.result {
font-size: 18px;
color: #ffffff;
}
:deep(.n-input-number) {
width: 100%;
}
:deep(.n-input-number-input) {
text-align: left;
}
:deep(.n-select) {
width: 100%;
}
:deep(.n-divider) {
margin: 16px 0;
}
</style>

View file

@ -0,0 +1,12 @@
import { AspectRatio } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Aspect Ratio Calculator',
path: '/aspect-ratio-calculator',
description: 'Use this ratio calculator to check the dimensions when resizing images.',
keywords: ['aspect', 'ratio', 'calculator'],
component: () => import('./aspect-ratio-calculator.vue'),
icon: AspectRatio,
createdAt: new Date('2024-08-14'),
});

View file

@ -1,6 +1,7 @@
import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter'; import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as aspectRatioCalculator } from './aspect-ratio-calculator';
import { tool as asciiTextDrawer } from './ascii-text-drawer'; import { tool as asciiTextDrawer } from './ascii-text-drawer';
@ -136,7 +137,7 @@ export const toolsByCategory: ToolCategory[] = [
}, },
{ {
name: 'Images and videos', name: 'Images and videos',
components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder], components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder, aspectRatioCalculator],
}, },
{ {
name: 'Development', name: 'Development',