Optimize simulation with logic-only solver, fix rectangular grid support, and improve worker pool
All checks were successful
Deploy to Production / deploy (push) Successful in 8s

This commit is contained in:
2026-02-13 05:18:55 +01:00
parent 48def6c400
commit 2cd32d4a3e
15 changed files with 641 additions and 282 deletions

View File

@@ -93,6 +93,7 @@ const handlePointerCancel = (e) => {
height: var(--cell-size);
background-color: var(--cell-empty);
border: 1px solid var(--glass-border);
box-sizing: border-box;
cursor: pointer;
display: flex;
justify-content: center;

View File

@@ -10,6 +10,10 @@ const store = usePuzzleStore();
const { rowHints, colHints } = useHints(computed(() => store.solution));
const { startDrag, onMouseEnter, stopDrag } = useNonogram();
// Compute grid dimensions from hints
const gridRows = computed(() => rowHints.value.length);
const gridCols = computed(() => colHints.value.length);
const cellSize = ref(30);
const rowHintsRef = ref(null);
const activeRow = ref(null);
@@ -143,13 +147,16 @@ const computeCellSize = () => {
// Ensure we don't have negative space
const availableForGrid = Math.max(0, containerWidth - hintWidth);
const size = Math.floor((availableForGrid - gridPad * 2 - (store.size - 1) * gap) / store.size);
// Calculate cell size based on width availability (columns)
// Vertical scrolling is acceptable, so we don't constrain by height (rows)
const cols = Math.max(1, gridCols.value);
const size = Math.floor((availableForGrid - gridPad * 2 - (cols - 1) * gap) / cols);
if (isDesktop) {
// Desktop: Allow overflow, use comfortable size
cellSize.value = 30;
} else {
// Mobile: Fit to screen
// Mobile: Fit to screen width
// Keep min 18, max 36
cellSize.value = Math.max(18, Math.min(36, size));
}
@@ -240,17 +247,17 @@ watch(() => store.size, async () => {
<div class="corner-spacer"></div>
<!-- Column Hints -->
<Hints :hints="colHints" orientation="col" :size="store.size" :activeIndex="activeCol" />
<Hints :hints="colHints" orientation="col" :size="gridCols" :activeIndex="activeCol" />
<!-- Row Hints -->
<Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="store.size" :activeIndex="activeRow" />
<Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="gridRows" :activeIndex="activeRow" />
<!-- Grid -->
<div
class="grid"
:style="{
gridTemplateColumns: `repeat(${store.size}, var(--cell-size))`,
gridTemplateRows: `repeat(${store.size}, var(--cell-size))`
gridTemplateColumns: `repeat(${gridCols}, var(--cell-size))`,
gridTemplateRows: `repeat(${gridRows}, var(--cell-size))`
}"
@pointermove.prevent="handlePointerMove"
@mouseleave="handleGridLeave"
@@ -263,8 +270,8 @@ watch(() => store.size, async () => {
:r="r"
:c="c"
:class="{
'guide-right': (c + 1) % 5 === 0 && c !== store.size - 1,
'guide-bottom': (r + 1) % 5 === 0 && r !== store.size - 1
'guide-right': (c + 1) % 5 === 0 && c !== gridCols - 1,
'guide-bottom': (r + 1) % 5 === 0 && r !== gridRows - 1
}"
@start-drag="startDrag"
@enter-cell="handleCellEnter"

View File

@@ -58,13 +58,13 @@ defineProps({
.hints-container.col {
padding-bottom: var(--grid-padding);
align-items: flex-end;
/* align-items: flex-end; - Removed to ensure uniform column height */
padding-left: var(--grid-padding);
padding-right: var(--grid-padding);
}
.hints-container.row {
align-items: flex-end;
/* align-items: flex-end; - Removed to ensure row hints fill the cell height */
padding: var(--grid-padding) var(--grid-padding) var(--grid-padding) 0;
width: max-content;
}
@@ -99,6 +99,21 @@ defineProps({
padding: 2px;
}
@media (max-width: 768px) {
.hint-num {
font-size: 0.7rem;
padding: 1px;
}
.col .hint-group {
padding: 2px 0;
}
.row .hint-group {
padding: 0 4px;
}
}
/* Alternating Colors within the group */
.hint-num.hint-alt {
color: var(--accent-cyan);

View File

@@ -225,16 +225,36 @@ const calculateStats = async () => {
try {
const pool = getWorkerPool();
pool.clearQueue(); // Clear pending tasks
pool.cancelAll(); // Force stop previous calculations for immediate responsiveness
const result = await pool.run({
id: requestId,
grid: generatedGrid.value
}, (progress) => {
if (currentStatsRequestId === requestId) {
processingProgress.value = progress;
// Demonstrate parallel execution capability
// We split the problem into two branches: assuming first cell is EMPTY (0) vs FILLED (1)
// This doubles the search power by utilizing 2 workers immediately.
// Ensure we send plain objects to workers, not Vue proxies
const rawGrid = JSON.parse(JSON.stringify(generatedGrid.value));
const rows = rawGrid.length;
const cols = rawGrid[0].length;
// Create initial states
const gridA = Array(rows).fill().map(() => Array(cols).fill(-1));
gridA[0][0] = 0; // Branch A: Assume (0,0) is Empty
const gridB = Array(rows).fill().map(() => Array(cols).fill(-1));
gridB[0][0] = 1; // Branch B: Assume (0,0) is Filled
const tasks = [
{
data: { id: requestId, grid: rawGrid, initialGrid: gridA },
onProgress: (p) => { if (currentStatsRequestId === requestId) processingProgress.value = Math.max(processingProgress.value, p); }
},
{
data: { id: requestId, grid: rawGrid, initialGrid: gridB },
onProgress: (p) => { if (currentStatsRequestId === requestId) processingProgress.value = Math.max(processingProgress.value, p); }
}
});
];
const result = await pool.runRace(tasks);
if (result.id === currentStatsRequestId) {
solvability.value = result.solvability;
@@ -242,12 +262,14 @@ const calculateStats = async () => {
difficultyLabel.value = result.difficultyLabel;
}
} catch (err) {
if (err.message !== 'Cancelled') {
if (err.message !== 'Cancelled' && err.message !== 'Terminated') {
console.error('Worker error:', err);
if (currentStatsRequestId === requestId) {
solvability.value = 0;
difficulty.value = 0;
difficultyLabel.value = 'unknown';
// If translation key is missing, this might show 'difficulty.error'
// Ensure we have a fallback or the key exists
difficultyLabel.value = 'error';
}
}
} finally {
@@ -266,6 +288,11 @@ watch([maxDimension, threshold], () => {
debounceTimer = setTimeout(() => {
updateGrid();
}, 50);
} else {
// If no image loaded, just update the display values
// Assuming square aspect ratio for preview
gridRows.value = maxDimension.value;
gridCols.value = maxDimension.value;
}
});
@@ -543,6 +570,8 @@ onUnmounted(() => {
position: relative;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
color: var(--text-color);
max-height: 90vh;
overflow-y: auto;
}
.close-btn {

View File

@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n';
import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor, Image as ImageIcon } from 'lucide-vue-next';
import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor, Image as ImageIcon, Sparkles, Shuffle, Grid3X3, Grid2X2, Grid, MousePointer2 } from 'lucide-vue-next';
const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n();
@@ -16,6 +16,13 @@ const isMobileMenuOpen = ref(false);
const langMenuRef = ref(null);
const searchTerm = ref('');
const getLevelIcon = (id) => {
if (id === 'easy') return Grid2X2;
if (id === 'medium') return Grid3X3;
if (id === 'hard') return Grid;
return Gamepad2;
};
// Map language codes to country codes for flag-icons
const langToCountry = {
en: 'gb',
@@ -257,12 +264,15 @@ watch(isMobileMenuOpen, (val) => {
class="dropdown-item"
@click="selectLevel(lvl.id)"
>
<component :is="getLevelIcon(lvl.id)" :size="16" />
{{ lvl.label }}
</button>
<button class="dropdown-item" @click="openCustom">
<Shuffle :size="16" />
{{ t('level.custom_random') }}
</button>
<button class="dropdown-item" @click="openImageImport">
<ImageIcon :size="16" />
{{ t('level.custom_image') }}
</button>
</div>
@@ -357,12 +367,15 @@ watch(isMobileMenuOpen, (val) => {
class="mobile-sub-item"
@click="selectLevel(lvl.id)"
>
<component :is="getLevelIcon(lvl.id)" :size="16" />
{{ lvl.label }}
</button>
<button class="mobile-sub-item" @click="openCustom">
<Shuffle :size="16" />
{{ t('level.custom_random') }}
</button>
<button class="mobile-sub-item" @click="openImageImport">
<ImageIcon :size="16" />
{{ t('level.custom_image') }}
</button>
</div>

View File

@@ -71,11 +71,12 @@ const startSimulation = async () => {
for (let i = 0; i < SAMPLES_PER_POINT; i++) {
const grid = generateRandomGrid(size, density);
const { rowHints, colHints } = calculateHints(grid);
const { percentSolved } = solvePuzzle(rowHints, colHints);
// Use logicOnly=true for fast simulation
const { percentSolved } = solvePuzzle(rowHints, colHints, null, null, true);
totalSolved += percentSolved;
// Yield to UI every few samples to keep it responsive
if (i % 2 === 0) await new Promise(r => setTimeout(r, 0));
if (i % 10 === 0) await new Promise(r => setTimeout(r, 0));
}
const avgSolved = totalSolved / SAMPLES_PER_POINT;