[FEAT] add grid metrics

This commit is contained in:
2026-01-01 17:14:21 +01:00
parent 281a37fa7e
commit 1f9fc4d5bb
5 changed files with 352 additions and 128 deletions

View File

@@ -0,0 +1,113 @@
import { computed, type Ref, type ComputedRef } from 'vue';
export interface BackgroundStylesOptions {
backgroundColor: Ref<string> | ComputedRef<string> | string;
checkerboardEnabled?: Ref<boolean> | ComputedRef<boolean> | boolean;
darkMode?: Ref<boolean> | ComputedRef<boolean> | boolean;
}
export interface BackgroundStyles {
backgroundColor: string;
backgroundImage: string;
backgroundSize: string;
backgroundPosition: string;
}
/**
* Composable for generating consistent background styles across components.
* Handles transparent backgrounds with checkerboard patterns and dark mode.
*/
export function useBackgroundStyles(options: BackgroundStylesOptions) {
// Helper to get reactive values
const getBackgroundColor = () => (typeof options.backgroundColor === 'string' ? options.backgroundColor : options.backgroundColor.value);
const getCheckerboardEnabled = () => (typeof options.checkerboardEnabled === 'boolean' ? options.checkerboardEnabled : options.checkerboardEnabled?.value ?? true);
const getDarkMode = () => (typeof options.darkMode === 'boolean' ? options.darkMode : options.darkMode?.value ?? false);
/**
* Get the background color.
*/
const backgroundColor = computed(() => {
const bg = getBackgroundColor();
return bg === 'transparent' ? 'transparent' : bg;
});
/**
* Get the background image (checkerboard pattern for transparent backgrounds).
*/
const backgroundImage = computed(() => {
const bg = getBackgroundColor();
const checkerboardEnabled = getCheckerboardEnabled();
const darkMode = getDarkMode();
if (bg === 'transparent' && checkerboardEnabled) {
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
const color = darkMode ? '#4b5563' : '#d1d5db';
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
}
return 'none';
});
/**
* Get the background size.
*/
const backgroundSize = computed(() => {
const bg = getBackgroundColor();
return bg === 'transparent' ? '20px 20px' : 'auto';
});
/**
* Get the background position.
*/
const backgroundPosition = computed(() => {
const bg = getBackgroundColor();
return bg === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0';
});
/**
* Get all background styles as a single object.
*/
const backgroundStyles = computed<BackgroundStyles>(() => ({
backgroundColor: backgroundColor.value,
backgroundImage: backgroundImage.value,
backgroundSize: backgroundSize.value,
backgroundPosition: backgroundPosition.value,
}));
return {
backgroundColor,
backgroundImage,
backgroundSize,
backgroundPosition,
backgroundStyles,
};
}
/**
* Standalone helper to get background styles.
* Useful when you don't need the full composable.
*/
export function getBackgroundStyles(
backgroundColor: string,
options: {
checkerboardEnabled?: boolean;
darkMode?: boolean;
} = {}
): BackgroundStyles {
const checkerboardEnabled = options.checkerboardEnabled ?? true;
const darkMode = options.darkMode ?? false;
const bg = backgroundColor === 'transparent' ? 'transparent' : backgroundColor;
let bgImage = 'none';
if (backgroundColor === 'transparent' && checkerboardEnabled) {
const color = darkMode ? '#4b5563' : '#d1d5db';
bgImage = `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
}
return {
backgroundColor: bg,
backgroundImage: bgImage,
backgroundSize: backgroundColor === 'transparent' ? '20px 20px' : 'auto',
backgroundPosition: backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
};
}

View File

@@ -1,7 +1,6 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite, Layer } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
import { calculateNegativeSpacing } from './useNegativeSpacing';
import { useGridMetrics, type GridMetrics } from './useGridMetrics';
export interface CellPosition {
col: number;
@@ -70,70 +69,42 @@ export function useDragSprite(options: DragSpriteOptions) {
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
const highlightCell = ref<CellPosition | null>(null);
// Cache for max dimensions
const lastMaxWidth = ref(1);
const lastMaxHeight = ref(1);
// Use the new useGridMetrics composable for consistent calculations
const gridMetricsComposable = useGridMetrics({
layers: options.layers,
sprites: options.sprites,
negativeSpacingEnabled: options.negativeSpacingEnabled,
manualCellSizeEnabled: options.manualCellSizeEnabled,
manualCellWidth: options.manualCellWidth,
manualCellHeight: options.manualCellHeight,
});
const calculateMaxDimensions = () => {
const negativeSpacingEnabled = getNegativeSpacingEnabled();
const manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
const maxWidth = getManualCellWidth();
const maxHeight = getManualCellHeight();
// When manual cell size is used, negative spacing is not applied
const negativeSpacing = 0;
// Don't update lastMaxWidth/lastMaxHeight when in manual mode
return { maxWidth, maxHeight, negativeSpacing };
}
// Get all sprites to calculate dimensions from
// If layers are provided, use ALL layers (regardless of visibility) to keep canvas size stable
const layers = getLayers();
const spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
// Otherwise, calculate based on sprite dimensions across all visible layers
const base = getMaxDimensions(spritesToMeasure);
// When switching back from manual mode, reset to actual sprite dimensions
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
lastMaxWidth.value = baseMaxWidth;
lastMaxHeight.value = baseMaxHeight;
// Calculate negative spacing using shared composable
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
// Add negative spacing to expand each cell
const maxWidth = Math.round(baseMaxWidth + negativeSpacing);
const maxHeight = Math.round(baseMaxHeight + negativeSpacing);
return { maxWidth, maxHeight, negativeSpacing };
const calculateMaxDimensions = (): GridMetrics => {
return gridMetricsComposable.calculateCellDimensions();
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
const metrics = calculateMaxDimensions();
return sprites.map((sprite, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
const cellPos = gridMetricsComposable.getCellPosition(index, columns, metrics);
const canvasPos = gridMetricsComposable.getSpriteCanvasPosition(sprite, index, columns, metrics);
// With negative spacing, sprites are positioned at bottom-right of cell
// (spacing added to top and left)
return {
id: sprite.id,
canvasX: Math.round(col * maxWidth + negativeSpacing + sprite.x),
canvasY: Math.round(row * maxHeight + negativeSpacing + sprite.y),
cellX: Math.round(col * maxWidth),
cellY: Math.round(row * maxHeight),
canvasX: canvasPos.canvasX,
canvasY: canvasPos.canvasY,
cellX: canvasPos.cellX,
cellY: canvasPos.cellY,
width: Math.round(sprite.width),
height: Math.round(sprite.height),
maxWidth: Math.round(maxWidth),
maxHeight: Math.round(maxHeight),
col,
row,
maxWidth: metrics.maxWidth,
maxHeight: metrics.maxHeight,
col: cellPos.col,
row: cellPos.row,
index,
x: sprite.x,
y: sprite.y,

View File

@@ -0,0 +1,165 @@
import { computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite, Layer } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
import { calculateNegativeSpacing } from './useNegativeSpacing';
export interface GridMetrics {
maxWidth: number;
maxHeight: number;
negativeSpacing: number;
}
export interface CellPosition {
x: number;
y: number;
col: number;
row: number;
}
export interface SpriteCanvasPosition {
canvasX: number;
canvasY: number;
cellX: number;
cellY: number;
}
export interface GridMetricsOptions {
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
sprites?: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
negativeSpacingEnabled?: Ref<boolean> | boolean;
manualCellSizeEnabled?: Ref<boolean> | boolean;
manualCellWidth?: Ref<number> | number;
manualCellHeight?: Ref<number> | number;
}
/**
* Composable for calculating grid metrics and sprite positions.
* Provides a single source of truth for cell dimensions and positioning calculations.
*/
export function useGridMetrics(options: GridMetricsOptions = {}) {
// Helper to get reactive values
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
const getSprites = () => (options.sprites ? (Array.isArray(options.sprites) ? options.sprites : options.sprites.value) : []);
const getNegativeSpacingEnabled = () => (typeof options.negativeSpacingEnabled === 'boolean' ? options.negativeSpacingEnabled : options.negativeSpacingEnabled?.value ?? false);
const getManualCellSizeEnabled = () => (typeof options.manualCellSizeEnabled === 'boolean' ? options.manualCellSizeEnabled : options.manualCellSizeEnabled?.value ?? false);
const getManualCellWidth = () => (typeof options.manualCellWidth === 'number' ? options.manualCellWidth : options.manualCellWidth?.value ?? 64);
const getManualCellHeight = () => (typeof options.manualCellHeight === 'number' ? options.manualCellHeight : options.manualCellHeight?.value ?? 64);
/**
* Calculate cell dimensions including negative spacing.
* This is the canonical calculation used across all components.
*/
const calculateCellDimensions = (): GridMetrics => {
const manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
return {
maxWidth: Math.round(getManualCellWidth()),
maxHeight: Math.round(getManualCellHeight()),
negativeSpacing: 0,
};
}
// Get sprites to measure from layers or direct sprites array
const layers = getLayers();
const spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
// Calculate base dimensions from sprites
const base = getMaxDimensions(spritesToMeasure);
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
// Calculate negative spacing
const negativeSpacingEnabled = getNegativeSpacingEnabled();
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
// Add negative spacing to expand each cell
return {
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
negativeSpacing,
};
};
/**
* Get the position of a cell in the grid.
*/
const getCellPosition = (index: number, columns: number, metrics: GridMetrics): CellPosition => {
const col = index % columns;
const row = Math.floor(index / columns);
return {
x: Math.round(col * metrics.maxWidth),
y: Math.round(row * metrics.maxHeight),
col,
row,
};
};
/**
* Get the canvas position of a sprite within a cell.
* This is the final position where the sprite should be rendered.
*/
const getSpriteCanvasPosition = (sprite: Sprite, cellIndex: number, columns: number, metrics: GridMetrics): SpriteCanvasPosition => {
const cellPos = getCellPosition(cellIndex, columns, metrics);
return {
canvasX: Math.round(cellPos.x + metrics.negativeSpacing + sprite.x),
canvasY: Math.round(cellPos.y + metrics.negativeSpacing + sprite.y),
cellX: cellPos.x,
cellY: cellPos.y,
};
};
/**
* Computed grid metrics that automatically updates when dependencies change.
*/
const gridMetrics = computed(() => calculateCellDimensions());
return {
// Computed values
gridMetrics,
// Methods
calculateCellDimensions,
getCellPosition,
getSpriteCanvasPosition,
};
}
/**
* Standalone helper to calculate cell dimensions.
* Useful when you don't need the full composable.
*/
export function getGridMetrics(
spritesOrLayers: Sprite[] | Layer[],
options: {
negativeSpacingEnabled?: boolean;
manualCellSizeEnabled?: boolean;
manualCellWidth?: number;
manualCellHeight?: number;
} = {}
): GridMetrics {
if (options.manualCellSizeEnabled) {
return {
maxWidth: Math.round(options.manualCellWidth ?? 64),
maxHeight: Math.round(options.manualCellHeight ?? 64),
negativeSpacing: 0,
};
}
// Check if we have layers or sprites
const isLayers = spritesOrLayers.length > 0 && 'sprites' in spritesOrLayers[0];
const sprites = isLayers ? (spritesOrLayers as Layer[]).flatMap(l => l.sprites) : (spritesOrLayers as Sprite[]);
const base = getMaxDimensions(sprites);
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
const negativeSpacing = Math.round(calculateNegativeSpacing(sprites, options.negativeSpacingEnabled ?? false));
return {
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
negativeSpacing,
};
}