feat: enhance image import and solvability calculation (v1.13.0)
All checks were successful
Deploy to Production / deploy (push) Successful in 18s

- Implement non-linear threshold slider (histogram percentile method)
- Add real-time solvability calculation with progress indicator
- Improve solvability logic with generative lookahead (smash)
- Update ImageImportModal UI (alpha preview, grid size 5-80)
- Add missing translations and difficulty labels
- Optimize web worker pool with queue clearing and progress reporting
- Fix mobile camera support and UI layout
This commit is contained in:
2026-02-13 02:23:44 +01:00
parent f1f3f81466
commit 48def6c400
12 changed files with 1228 additions and 971 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-7a5e81cd'], (function (workbox) { 'use strict';
*/
workbox.precacheAndRoute([{
"url": "index.html",
"revision": "0.0dmrmul42fg"
"revision": "0.geiftdl7j9o"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -1,938 +0,0 @@
[
{
"size": 5,
"density": 0.1,
"avgSolved": 89.4,
"minSolved": 36,
"maxSolved": 100,
"avgTime": 0.031666799999999905
},
{
"size": 5,
"density": 0.2,
"avgSolved": 74.2,
"minSolved": 8,
"maxSolved": 100,
"avgTime": 0.03671869999999924
},
{
"size": 5,
"density": 0.3,
"avgSolved": 74.2,
"minSolved": 0,
"maxSolved": 100,
"avgTime": 0.04439559999999983
},
{
"size": 5,
"density": 0.4,
"avgSolved": 80.8,
"minSolved": 8,
"maxSolved": 100,
"avgTime": 0.0317166499999999
},
{
"size": 5,
"density": 0.5,
"avgSolved": 96.8,
"minSolved": 68,
"maxSolved": 100,
"avgTime": 0.0309604000000002
},
{
"size": 5,
"density": 0.6,
"avgSolved": 97.6,
"minSolved": 84,
"maxSolved": 100,
"avgTime": 0.031464499999999875
},
{
"size": 5,
"density": 0.7,
"avgSolved": 99.2,
"minSolved": 84,
"maxSolved": 100,
"avgTime": 0.03086874999999978
},
{
"size": 5,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.01615615000000048
},
{
"size": 5,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.02271474999999956
},
{
"size": 10,
"density": 0.1,
"avgSolved": 56.6,
"minSolved": 19,
"maxSolved": 86,
"avgTime": 0.042958299999999915
},
{
"size": 10,
"density": 0.2,
"avgSolved": 19.8,
"minSolved": 0,
"maxSolved": 51,
"avgTime": 0.050141749999999874
},
{
"size": 10,
"density": 0.3,
"avgSolved": 15.75,
"minSolved": 0,
"maxSolved": 73,
"avgTime": 0.06852290000000014
},
{
"size": 10,
"density": 0.4,
"avgSolved": 54.05,
"minSolved": 0,
"maxSolved": 100,
"avgTime": 0.12701870000000018
},
{
"size": 10,
"density": 0.5,
"avgSolved": 91.8,
"minSolved": 59,
"maxSolved": 100,
"avgTime": 0.16561034999999985
},
{
"size": 10,
"density": 0.6,
"avgSolved": 99.8,
"minSolved": 96,
"maxSolved": 100,
"avgTime": 0.07136649999999882
},
{
"size": 10,
"density": 0.7,
"avgSolved": 99.8,
"minSolved": 96,
"maxSolved": 100,
"avgTime": 0.04808134999999893
},
{
"size": 10,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.03795824999999979
},
{
"size": 10,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.024952100000000855
},
{
"size": 15,
"density": 0.1,
"avgSolved": 37.044444444444444,
"minSolved": 13.333333333333334,
"maxSolved": 61.77777777777778,
"avgTime": 0.045045850000000345
},
{
"size": 15,
"density": 0.2,
"avgSolved": 9.777777777777775,
"minSolved": 0,
"maxSolved": 26.666666666666668,
"avgTime": 0.034581349999998776
},
{
"size": 15,
"density": 0.3,
"avgSolved": 1.8888888888888886,
"minSolved": 0,
"maxSolved": 8,
"avgTime": 0.029402199999999823
},
{
"size": 15,
"density": 0.4,
"avgSolved": 11.822222222222223,
"minSolved": 0,
"maxSolved": 61.33333333333333,
"avgTime": 0.07898965000000047
},
{
"size": 15,
"density": 0.5,
"avgSolved": 68.19999999999999,
"minSolved": 2.666666666666667,
"maxSolved": 100,
"avgTime": 0.1374602999999997
},
{
"size": 15,
"density": 0.6,
"avgSolved": 99.55555555555554,
"minSolved": 96.44444444444444,
"maxSolved": 100,
"avgTime": 0.09379159999999978
},
{
"size": 15,
"density": 0.7,
"avgSolved": 99.77777777777779,
"minSolved": 97.33333333333334,
"maxSolved": 100,
"avgTime": 0.07072704999999928
},
{
"size": 15,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.050104250000000405
},
{
"size": 15,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.031362550000000766
},
{
"size": 20,
"density": 0.1,
"avgSolved": 22.5875,
"minSolved": 5,
"maxSolved": 41.5,
"avgTime": 0.04363335000000035
},
{
"size": 20,
"density": 0.2,
"avgSolved": 3.25,
"minSolved": 0,
"maxSolved": 14.499999999999998,
"avgTime": 0.03823525000000103
},
{
"size": 20,
"density": 0.3,
"avgSolved": 0.5625,
"minSolved": 0,
"maxSolved": 5,
"avgTime": 0.03880414999999786
},
{
"size": 20,
"density": 0.4,
"avgSolved": 1.4625,
"minSolved": 0,
"maxSolved": 3.25,
"avgTime": 0.06692695000000129
},
{
"size": 20,
"density": 0.5,
"avgSolved": 36.75,
"minSolved": 1.25,
"maxSolved": 99,
"avgTime": 0.25872084999999884
},
{
"size": 20,
"density": 0.6,
"avgSolved": 99.8,
"minSolved": 99,
"maxSolved": 100,
"avgTime": 0.2258772000000004
},
{
"size": 20,
"density": 0.7,
"avgSolved": 99.95,
"minSolved": 99,
"maxSolved": 100,
"avgTime": 0.13418124999999997
},
{
"size": 20,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.09264785000000053
},
{
"size": 20,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.05307699999999756
},
{
"size": 25,
"density": 0.1,
"avgSolved": 16.000000000000004,
"minSolved": 7.84,
"maxSolved": 32.800000000000004,
"avgTime": 0.05678540000000112
},
{
"size": 25,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.04278334999999842
},
{
"size": 25,
"density": 0.3,
"avgSolved": 0.048,
"minSolved": 0,
"maxSolved": 0.64,
"avgTime": 0.05884794999999983
},
{
"size": 25,
"density": 0.4,
"avgSolved": 0.8880000000000001,
"minSolved": 0,
"maxSolved": 9.44,
"avgTime": 0.11761245000000287
},
{
"size": 25,
"density": 0.5,
"avgSolved": 19.128000000000007,
"minSolved": 1.1199999999999999,
"maxSolved": 96.48,
"avgTime": 0.3490229000000021
},
{
"size": 25,
"density": 0.6,
"avgSolved": 99.24799999999998,
"minSolved": 94.88,
"maxSolved": 100,
"avgTime": 0.49611459999999996
},
{
"size": 25,
"density": 0.7,
"avgSolved": 99.904,
"minSolved": 99.36,
"maxSolved": 100,
"avgTime": 0.23916465000000073
},
{
"size": 25,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.14604994999999973
},
{
"size": 25,
"density": 0.9,
"avgSolved": 99.96799999999999,
"minSolved": 99.36,
"maxSolved": 100,
"avgTime": 0.08385419999999896
},
{
"size": 30,
"density": 0.1,
"avgSolved": 7.988888888888889,
"minSolved": 0,
"maxSolved": 16,
"avgTime": 0.08026245000000073
},
{
"size": 30,
"density": 0.2,
"avgSolved": 0.16666666666666669,
"minSolved": 0,
"maxSolved": 3.3333333333333335,
"avgTime": 0.06999999999999887
},
{
"size": 30,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.08285835000000005
},
{
"size": 30,
"density": 0.4,
"avgSolved": 0.3777777777777777,
"minSolved": 0,
"maxSolved": 4.111111111111112,
"avgTime": 0.1756041499999988
},
{
"size": 30,
"density": 0.5,
"avgSolved": 5.4222222222222225,
"minSolved": 0.7777777777777778,
"maxSolved": 21.444444444444443,
"avgTime": 0.41105620000000015
},
{
"size": 30,
"density": 0.6,
"avgSolved": 99.41666666666669,
"minSolved": 94.77777777777779,
"maxSolved": 100,
"avgTime": 0.9417500999999995
},
{
"size": 30,
"density": 0.7,
"avgSolved": 99.93333333333335,
"minSolved": 99.55555555555556,
"maxSolved": 100,
"avgTime": 0.41628955000000334
},
{
"size": 30,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.22320620000000133
},
{
"size": 30,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.13331460000000134
},
{
"size": 35,
"density": 0.1,
"avgSolved": 5.653061224489796,
"minSolved": 0,
"maxSolved": 13.795918367346937,
"avgTime": 0.11177699999999931
},
{
"size": 35,
"density": 0.2,
"avgSolved": 0.14285714285714285,
"minSolved": 0,
"maxSolved": 2.857142857142857,
"avgTime": 0.09598544999999917
},
{
"size": 35,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.1290145000000038
},
{
"size": 35,
"density": 0.4,
"avgSolved": 0.1346938775510204,
"minSolved": 0,
"maxSolved": 0.5714285714285714,
"avgTime": 0.21904799999999797
},
{
"size": 35,
"density": 0.5,
"avgSolved": 4.424489795918367,
"minSolved": 0.40816326530612246,
"maxSolved": 23.183673469387756,
"avgTime": 0.5596769500000022
},
{
"size": 35,
"density": 0.6,
"avgSolved": 91.1061224489796,
"minSolved": 8.408163265306122,
"maxSolved": 100,
"avgTime": 1.5827311000000024
},
{
"size": 35,
"density": 0.7,
"avgSolved": 99.9673469387755,
"minSolved": 99.67346938775509,
"maxSolved": 100,
"avgTime": 0.5970167499999988
},
{
"size": 35,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.33084175000000327
},
{
"size": 35,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.1685022000000032
},
{
"size": 40,
"density": 0.1,
"avgSolved": 2.734375,
"minSolved": 0,
"maxSolved": 9.8125,
"avgTime": 0.13156869999999826
},
{
"size": 40,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.13052910000000112
},
{
"size": 40,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.17675199999999905
},
{
"size": 40,
"density": 0.4,
"avgSolved": 0.03125,
"minSolved": 0,
"maxSolved": 0.3125,
"avgTime": 0.26616039999999686
},
{
"size": 40,
"density": 0.5,
"avgSolved": 2.14375,
"minSolved": 0,
"maxSolved": 9.5625,
"avgTime": 0.694316649999999
},
{
"size": 40,
"density": 0.6,
"avgSolved": 91.44375,
"minSolved": 22.3125,
"maxSolved": 100,
"avgTime": 2.9244042000000006
},
{
"size": 40,
"density": 0.7,
"avgSolved": 99.9875,
"minSolved": 99.75,
"maxSolved": 100,
"avgTime": 0.8381519999999967
},
{
"size": 40,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.4339062999999925
},
{
"size": 40,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.2375938000000076
},
{
"size": 45,
"density": 0.1,
"avgSolved": 1.7827160493827159,
"minSolved": 0,
"maxSolved": 4.691358024691358,
"avgTime": 0.1660813500000046
},
{
"size": 45,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.1715666999999968
},
{
"size": 45,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.23760415000000706
},
{
"size": 45,
"density": 0.4,
"avgSolved": 0.012345679012345678,
"minSolved": 0,
"maxSolved": 0.14814814814814814,
"avgTime": 0.333931249999992
},
{
"size": 45,
"density": 0.5,
"avgSolved": 1.439506172839506,
"minSolved": 0.39506172839506176,
"maxSolved": 5.135802469135802,
"avgTime": 0.9644125499999916
},
{
"size": 45,
"density": 0.6,
"avgSolved": 81.71851851851852,
"minSolved": 6.0246913580246915,
"maxSolved": 100,
"avgTime": 5.281324949999998
},
{
"size": 45,
"density": 0.7,
"avgSolved": 99.94074074074075,
"minSolved": 99.4074074074074,
"maxSolved": 100,
"avgTime": 1.2768960000000107
},
{
"size": 45,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.638566650000007
},
{
"size": 45,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.3004915999999923
},
{
"size": 50,
"density": 0.1,
"avgSolved": 1.7,
"minSolved": 0,
"maxSolved": 5.92,
"avgTime": 0.20294785000000387
},
{
"size": 50,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.23199789999999892
},
{
"size": 50,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.29876259999999205
},
{
"size": 50,
"density": 0.4,
"avgSolved": 0.008,
"minSolved": 0,
"maxSolved": 0.16,
"avgTime": 0.38459799999998834
},
{
"size": 50,
"density": 0.5,
"avgSolved": 0.5099999999999999,
"minSolved": 0,
"maxSolved": 1.7999999999999998,
"avgTime": 0.8961771499999941
},
{
"size": 50,
"density": 0.6,
"avgSolved": 73.258,
"minSolved": 5.6000000000000005,
"maxSolved": 100,
"avgTime": 7.937735449999991
},
{
"size": 50,
"density": 0.7,
"avgSolved": 99.96399999999998,
"minSolved": 99.76,
"maxSolved": 100,
"avgTime": 1.6324250000000062
},
{
"size": 50,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.8293270000000064
},
{
"size": 50,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.41459575000000654
},
{
"size": 60,
"density": 0.1,
"avgSolved": 0.16666666666666669,
"minSolved": 0,
"maxSolved": 1.6666666666666667,
"avgTime": 0.2432124999999928
},
{
"size": 60,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.35082704999999237
},
{
"size": 60,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.49827310000000014
},
{
"size": 60,
"density": 0.4,
"avgSolved": 0.0027777777777777775,
"minSolved": 0,
"maxSolved": 0.05555555555555555,
"avgTime": 0.6393062499999985
},
{
"size": 60,
"density": 0.5,
"avgSolved": 0.23055555555555554,
"minSolved": 0,
"maxSolved": 1.6666666666666667,
"avgTime": 1.2402395500000012
},
{
"size": 60,
"density": 0.6,
"avgSolved": 35.01805555555556,
"minSolved": 1.3333333333333335,
"maxSolved": 100,
"avgTime": 10.759754149999992
},
{
"size": 60,
"density": 0.7,
"avgSolved": 99.96944444444445,
"minSolved": 99.83333333333333,
"maxSolved": 100,
"avgTime": 2.964204100000029
},
{
"size": 60,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 1.2736664999999903
},
{
"size": 60,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.6249353999999812
},
{
"size": 70,
"density": 0.1,
"avgSolved": 0.14285714285714285,
"minSolved": 0,
"maxSolved": 1.4285714285714286,
"avgTime": 0.34277719999998907
},
{
"size": 70,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.5435105000000249
},
{
"size": 70,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.7600602999999865
},
{
"size": 70,
"density": 0.4,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.9591250999999943
},
{
"size": 70,
"density": 0.5,
"avgSolved": 0.04081632653061225,
"minSolved": 0,
"maxSolved": 0.2857142857142857,
"avgTime": 1.491010399999982
},
{
"size": 70,
"density": 0.6,
"avgSolved": 16.403061224489797,
"minSolved": 1,
"maxSolved": 99.71428571428571,
"avgTime": 22.21432699999999
},
{
"size": 70,
"density": 0.7,
"avgSolved": 99.96836734693878,
"minSolved": 99.73469387755102,
"maxSolved": 100,
"avgTime": 4.92020829999999
},
{
"size": 70,
"density": 0.8,
"avgSolved": 99.99591836734695,
"minSolved": 99.91836734693878,
"maxSolved": 100,
"avgTime": 2.0306394499999554
},
{
"size": 70,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 0.8882499500000336
},
{
"size": 80,
"density": 0.1,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.4418666499999858
},
{
"size": 80,
"density": 0.2,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 0.7795667999999978
},
{
"size": 80,
"density": 0.3,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 1.0745101999999747
},
{
"size": 80,
"density": 0.4,
"avgSolved": 0,
"minSolved": 0,
"maxSolved": 0,
"avgTime": 1.3407041500000105
},
{
"size": 80,
"density": 0.5,
"avgSolved": 0.0125,
"minSolved": 0,
"maxSolved": 0.078125,
"avgTime": 1.9724897000000283
},
{
"size": 80,
"density": 0.6,
"avgSolved": 1.21484375,
"minSolved": 0.40625,
"maxSolved": 2.296875,
"avgTime": 3.9163123999999927
},
{
"size": 80,
"density": 0.7,
"avgSolved": 99.978125,
"minSolved": 99.9375,
"maxSolved": 100,
"avgTime": 7.790070799999967
},
{
"size": 80,
"density": 0.8,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 3.1350061999999754
},
{
"size": 80,
"density": 0.9,
"avgSolved": 100,
"minSolved": 100,
"maxSolved": 100,
"avgTime": 1.3134414999999535
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "vue-nonograms-solid",
"version": "1.12.11",
"version": "1.13.0",
"homepage": "https://nonograms.7u.pl/",
"type": "module",
"scripts": {

View File

@@ -8,6 +8,7 @@ import StatusPanel from './components/StatusPanel.vue';
import GuidePanel from './components/GuidePanel.vue';
import WinModal from './components/WinModal.vue';
import CustomGameModal from './components/CustomGameModal.vue';
import ImageImportModal from './components/ImageImportModal.vue';
import SimulationView from './components/SimulationView.vue';
import FixedBar from './components/FixedBar.vue';
import ReloadPrompt from './components/ReloadPrompt.vue';
@@ -16,6 +17,7 @@ import ReloadPrompt from './components/ReloadPrompt.vue';
const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n();
const showCustomModal = ref(false);
const showImageImportModal = ref(false);
const showSimulation = ref(false);
const showGuide = ref(false);
const deferredPrompt = ref(null);
@@ -39,6 +41,10 @@ const onKeyDownGlobal = (e) => {
showCustomModal.value = false;
return;
}
if (showImageImportModal.value) {
showImageImportModal.value = false;
return;
}
if (store.isGameWon) {
store.closeWinModal();
}
@@ -158,6 +164,7 @@ onUnmounted(() => {
<main class="game-container">
<NavBar
@open-custom="showCustomModal = true"
@open-image-import="showImageImportModal = true"
@toggle-guide="showGuide = !showGuide"
@set-theme="setThemePreference"
/>
@@ -207,6 +214,10 @@ onUnmounted(() => {
<Teleport to="body">
<WinModal v-if="store.isGameWon" />
<CustomGameModal v-if="showCustomModal" @close="showCustomModal = false" @open-simulation="showSimulation = true" />
<ImageImportModal
v-if="showImageImportModal"
@close="showImageImportModal = false"
/>
<SimulationView v-if="showSimulation" @close="showSimulation = false" />
<ReloadPrompt />
</Teleport>

View File

@@ -0,0 +1,823 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n';
import { getWorkerPool } from '@/utils/workerPool';
import { Upload, Image as ImageIcon, X, AlertTriangle, Camera, RefreshCw } from 'lucide-vue-next';
const emit = defineEmits(['close']);
const store = usePuzzleStore();
const { t } = useI18n();
const fileInput = ref(null);
const canvasRef = ref(null);
const previewCanvasRef = ref(null);
const videoRef = ref(null);
const isDragging = ref(false);
const imageLoaded = ref(false);
const processing = ref(false);
const processingProgress = ref(0);
const isCameraOpen = ref(false);
const stream = ref(null);
const facingMode = ref('environment');
// Settings
const maxDimension = ref(15);
const threshold = ref(50); // 0-100% density
// State
const originalImage = ref(null);
const generatedGrid = ref([]);
const difficulty = ref(0);
const solvability = ref(0);
const difficultyLabel = ref('');
const gridRows = ref(15);
const gridCols = ref(15);
const onDrop = (e) => {
isDragging.value = false;
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
processFile(file);
}
};
const onFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
processFile(file);
}
};
const processFile = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
originalImage.value = img;
imageLoaded.value = true;
nextTick(() => {
updateGrid();
});
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
const updateGrid = () => {
if (!originalImage.value || !canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
// Calculate dimensions preserving aspect ratio
const imgW = originalImage.value.width;
const imgH = originalImage.value.height;
const aspect = imgW / imgH;
let w, h;
const maxDim = maxDimension.value;
if (aspect >= 1) {
w = maxDim;
h = Math.round(w / aspect);
} else {
h = maxDim;
w = Math.round(h * aspect);
}
// Ensure bounds [5, 80]
// 1. Clamp min (prioritize min 5)
if (w < 5) { w = 5; h = Math.round(w / aspect); }
if (h < 5) { h = 5; w = Math.round(h * aspect); }
// 2. Clamp max (hard limit 80)
if (w > 80) w = 80;
if (h > 80) h = 80;
// Final safeguard
w = Math.max(5, Math.min(80, Math.round(w)));
h = Math.max(5, Math.min(80, Math.round(h)));
gridRows.value = h;
gridCols.value = w;
// Resize logic
canvasRef.value.width = w;
canvasRef.value.height = h;
// Draw image scaled to grid size
ctx.drawImage(originalImage.value, 0, 0, w, h);
// Get pixel data
const imageData = ctx.getImageData(0, 0, w, h);
const data = imageData.data;
// 1. Collect brightness of valid pixels
const pixels = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
// If transparent, ignore
if (a >= 128) {
// Calculate brightness (Luminance)
const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
pixels.push({ x, y, brightness });
}
}
}
// 2. Sort by brightness (ascending: dark -> light)
// Darker pixels have lower brightness.
// We want to fill the darkest X% pixels.
pixels.sort((a, b) => a.brightness - b.brightness);
// 3. Determine cutoff
// threshold.value is density (0-100%)
// If density is 50%, we want 50% of pixels to be black.
// So we take the first 50% of sorted pixels.
const totalValid = pixels.length;
const targetFillCount = Math.floor(totalValid * (threshold.value / 100));
// 4. Build grid
const grid = Array(h).fill().map(() => Array(w).fill(0));
// Mark the darkest pixels as filled (1)
for (let i = 0; i < targetFillCount; i++) {
const p = pixels[i];
grid[p.y][p.x] = 1;
}
generatedGrid.value = grid;
drawPreview();
calculateStats();
};
const drawPreview = () => {
if (!previewCanvasRef.value || generatedGrid.value.length === 0) return;
const ctx = previewCanvasRef.value.getContext('2d');
const rows = generatedGrid.value.length;
const cols = generatedGrid.value[0].length;
// Max 300px width/height, preserving aspect
const maxPreview = 300;
const cellW = maxPreview / Math.max(rows, cols);
const cellH = cellW; // square cells
const previewW = cols * cellW;
const previewH = rows * cellH;
previewCanvasRef.value.width = previewW;
previewCanvasRef.value.height = previewH;
ctx.clearRect(0, 0, previewW, previewH);
// Draw original image with low opacity
if (originalImage.value) {
ctx.save();
ctx.globalAlpha = 0.3;
ctx.drawImage(originalImage.value, 0, 0, previewW, previewH);
ctx.restore();
}
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (generatedGrid.value[y][x] === 1) {
ctx.fillStyle = '#000';
ctx.fillRect(x * cellW, y * cellH, cellW, cellH);
}
}
}
// Grid lines
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 0.5;
// Vertical lines
for (let i = 0; i <= cols; i++) {
ctx.beginPath();
ctx.moveTo(i * cellW, 0);
ctx.lineTo(i * cellW, previewH);
ctx.stroke();
}
// Horizontal lines
for (let i = 0; i <= rows; i++) {
ctx.beginPath();
ctx.moveTo(0, i * cellH);
ctx.lineTo(previewW, i * cellH);
ctx.stroke();
}
};
const calculateStats = async () => {
if (generatedGrid.value.length === 0) return;
processing.value = true;
processingProgress.value = 0;
const requestId = Date.now();
currentStatsRequestId = requestId;
try {
const pool = getWorkerPool();
pool.clearQueue(); // Clear pending tasks
const result = await pool.run({
id: requestId,
grid: generatedGrid.value
}, (progress) => {
if (currentStatsRequestId === requestId) {
processingProgress.value = progress;
}
});
if (result.id === currentStatsRequestId) {
solvability.value = result.solvability;
difficulty.value = result.difficulty;
difficultyLabel.value = result.difficultyLabel;
}
} catch (err) {
if (err.message !== 'Cancelled') {
console.error('Worker error:', err);
if (currentStatsRequestId === requestId) {
solvability.value = 0;
difficulty.value = 0;
difficultyLabel.value = 'unknown';
}
}
} finally {
if (currentStatsRequestId === requestId) {
processing.value = false;
}
}
};
let currentStatsRequestId = 0;
let debounceTimer;
watch([maxDimension, threshold], () => {
if (imageLoaded.value) {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updateGrid();
}, 50);
}
});
const createPuzzle = () => {
if (generatedGrid.value.length > 0) {
store.initFromImage(generatedGrid.value);
emit('close');
}
};
const triggerFileInput = () => {
fileInput.value.click();
};
const startCamera = async () => {
isCameraOpen.value = true;
try {
if (stream.value) {
stopCameraStream();
}
const constraints = {
video: {
facingMode: facingMode.value
}
};
stream.value = await navigator.mediaDevices.getUserMedia(constraints);
// Wait for next tick or ensure videoRef is available
setTimeout(() => {
if (videoRef.value) {
videoRef.value.srcObject = stream.value;
}
}, 100);
} catch (err) {
console.error("Error accessing camera:", err);
alert(t('image.cameraError'));
isCameraOpen.value = false;
}
};
const stopCameraStream = () => {
if (stream.value) {
stream.value.getTracks().forEach(track => track.stop());
stream.value = null;
}
};
const closeCamera = () => {
stopCameraStream();
isCameraOpen.value = false;
};
const switchCamera = async () => {
facingMode.value = facingMode.value === 'user' ? 'environment' : 'user';
await startCamera();
};
const capturePhoto = () => {
if (!videoRef.value) return;
const canvas = document.createElement('canvas');
canvas.width = videoRef.value.videoWidth;
canvas.height = videoRef.value.videoHeight;
const ctx = canvas.getContext('2d');
// Mirror if using front camera
if (facingMode.value === 'user') {
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height);
const img = new Image();
img.onload = () => {
originalImage.value = img;
imageLoaded.value = true;
nextTick(() => {
updateGrid();
});
closeCamera();
};
img.src = canvas.toDataURL('image/png');
};
onUnmounted(() => {
stopCameraStream();
});
</script>
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal-content">
<button class="close-btn" @click="$emit('close')">
<X :size="24" />
</button>
<h2 class="modal-title">{{ t('image.title') }}</h2>
<div v-if="isCameraOpen" class="camera-overlay">
<video ref="videoRef" autoplay playsinline></video>
<div class="camera-controls">
<button class="camera-btn secondary" @click="closeCamera">
<X :size="24" />
</button>
<button class="camera-btn capture" @click="capturePhoto">
<div class="shutter"></div>
</button>
<button class="camera-btn secondary" @click="switchCamera">
<RefreshCw :size="24" />
</button>
</div>
</div>
<div class="content-grid">
<!-- Left: Input & Preview -->
<div class="input-section">
<div
class="drop-zone"
:class="{ dragging: isDragging, loaded: imageLoaded }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
@click="!imageLoaded ? triggerFileInput() : null"
>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onFileSelect"
/>
<div v-if="!imageLoaded" class="placeholder">
<Upload :size="48" class="icon" />
<p>{{ t('image.drop') }}</p>
<div class="button-group">
<button class="btn-neon small" @click.stop="triggerFileInput">
{{ t('image.select') }}
</button>
<button class="btn-neon small secondary" @click.stop="startCamera">
<Camera :size="16" />
{{ t('image.camera') }}
</button>
</div>
</div>
<div v-else class="preview-container">
<canvas ref="previewCanvasRef" class="preview-canvas"></canvas>
<div class="button-group">
<button class="btn-change" @click.stop="triggerFileInput">
{{ t('image.change') }}
</button>
<button class="btn-change" @click.stop="startCamera">
<Camera :size="16" />
</button>
</div>
</div>
</div>
<!-- Hidden canvas for processing -->
<canvas ref="canvasRef" style="display: none;"></canvas>
</div>
<!-- Right: Controls & Stats -->
<div class="controls-section">
<div class="control-group">
<label>{{ t('image.size') }}: {{ gridCols }}x{{ gridRows }}</label>
<input
type="range"
v-model.number="maxDimension"
min="5"
max="80"
step="5"
/>
</div>
<div class="control-group">
<label>{{ t('image.threshold') }}: {{ threshold }}%</label>
<input
type="range"
v-model.number="threshold"
min="1"
max="99"
/>
<div class="threshold-preview">
<span>Low Density</span>
<span>High Density</span>
</div>
</div>
<div v-if="imageLoaded" class="stats-panel">
<div v-if="processing" class="loading-stats">
<div class="spinner"></div>
<span>{{ t('image.calculatingSolvability') || 'Calculating solvability...' }} {{ processingProgress }}%</span>
</div>
<template v-else>
<div class="stat-row">
<span>{{ t('image.solvability') }}:</span>
<span :class="{ 'good': solvability === 100, 'bad': solvability < 100 }">
{{ solvability }}%
</span>
</div>
<div v-if="solvability < 100" class="warning">
<AlertTriangle :size="16" />
{{ t('image.warning') }}
</div>
<div class="stat-row">
<span>{{ t('image.difficulty') }}:</span>
<span>{{ difficulty }}% ({{ t(`difficulty.${difficultyLabel.toLowerCase()}`) }})</span>
</div>
</template>
</div>
<div class="actions">
<button
class="btn-neon primary"
:disabled="!imageLoaded || processing"
@click="createPuzzle"
>
{{ t('image.create') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.loading-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px;
color: var(--text-muted);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--text-muted);
border-top-color: var(--primary-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 30px;
width: 90%;
max-width: 800px;
position: relative;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
color: var(--text-color);
}
.close-btn {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color 0.3s;
}
.close-btn:hover {
color: var(--text-color);
}
.modal-title {
text-align: center;
margin-bottom: 30px;
color: var(--primary-accent);
font-size: 1.8rem;
text-shadow: 0 0 10px var(--primary-accent);
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
}
.camera-overlay video {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-controls {
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
display: flex;
justify-content: space-around;
align-items: center;
padding: 20px;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
}
.camera-btn {
background: none;
border: none;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(5px);
}
.camera-btn.capture {
width: 70px;
height: 70px;
background: rgba(255,255,255,0.3);
border: 4px solid white;
}
.shutter {
width: 54px;
height: 54px;
background: white;
border-radius: 50%;
transition: transform 0.1s;
}
.camera-btn.capture:active .shutter {
transform: scale(0.9);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
justify-content: center;
}
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
@media (max-width: 768px) {
.content-grid {
grid-template-columns: 1fr;
}
}
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 12px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
cursor: pointer;
transition: all 0.3s;
background: rgba(255, 255, 255, 0.05);
}
.drop-zone:hover, .drop-zone.dragging {
border-color: var(--primary-accent);
background: rgba(0, 242, 254, 0.1);
}
.drop-zone.loaded {
border: none;
background: none;
cursor: default;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
color: var(--text-muted);
}
.hidden-input {
display: none;
}
.preview-container {
position: relative;
width: 300px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
}
.preview-container .button-group {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 8px;
margin: 0;
}
.preview-canvas {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
box-shadow: 0 0 15px rgba(0,0,0,0.3);
}
.btn-change {
background: rgba(0,0,0,0.7);
color: white;
border: 1px solid white;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
transition: background 0.2s;
}
.btn-change:hover {
background: rgba(0,0,0,0.9);
}
.controls-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 10px;
}
input[type="range"] {
width: 100%;
accent-color: var(--primary-accent);
}
.threshold-preview {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
}
.stats-panel {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.stat-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 1.1rem;
}
.good { color: #4ade80; }
.bad { color: #f87171; }
.warning {
color: #fbbf24;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 5px;
margin-top: -5px;
margin-bottom: 10px;
}
.actions {
margin-top: auto;
display: flex;
justify-content: center;
}
.btn-neon {
padding: 10px 20px;
border: 1px solid var(--primary-accent);
background: rgba(0, 242, 254, 0.1);
color: var(--primary-accent);
font-family: 'Orbitron', sans-serif;
text-transform: uppercase;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
border-radius: 4px;
}
.btn-neon:hover:not(:disabled) {
background: var(--primary-accent);
color: #000;
box-shadow: 0 0 20px var(--primary-accent);
}
.btn-neon:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
.btn-neon.small {
padding: 5px 15px;
font-size: 0.8rem;
}
</style>

View File

@@ -2,12 +2,12 @@
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 } from 'lucide-vue-next';
import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor, Image as ImageIcon } from 'lucide-vue-next';
const store = usePuzzleStore();
const { t, locale, setLocale, locales } = useI18n();
const emit = defineEmits(['open-custom', 'toggle-guide', 'set-theme']);
const emit = defineEmits(['open-custom', 'open-image-import', 'toggle-guide', 'set-theme']);
const isGameOpen = ref(false);
const isThemeOpen = ref(false);
@@ -180,6 +180,12 @@ const openCustom = () => {
closeMobileMenu();
};
const openImageImport = () => {
emit('open-image-import');
isGameOpen.value = false;
closeMobileMenu();
};
const setTheme = (theme) => {
emit('set-theme', theme);
isThemeOpen.value = false;
@@ -254,7 +260,10 @@ watch(isMobileMenuOpen, (val) => {
{{ lvl.label }}
</button>
<button class="dropdown-item" @click="openCustom">
{{ t('level.custom') }}
{{ t('level.custom_random') }}
</button>
<button class="dropdown-item" @click="openImageImport">
{{ t('level.custom_image') }}
</button>
</div>
</transition>
@@ -351,7 +360,10 @@ watch(isMobileMenuOpen, (val) => {
{{ lvl.label }}
</button>
<button class="mobile-sub-item" @click="openCustom">
{{ t('level.custom') }}
{{ t('level.custom_random') }}
</button>
<button class="mobile-sub-item" @click="openImageImport">
{{ t('level.custom_image') }}
</button>
</div>
</div>

View File

@@ -7,6 +7,8 @@ const messages = {
'level.medium': 'ŚREDNI 10X10',
'level.hard': 'TRUDNY 15X15',
'level.custom': 'WŁASNY',
'level.custom_random': 'WŁASNY LOSOWY',
'level.custom_image': 'WŁASNY Z OBRAZU',
'level.guide': 'PODPOWIEDŹ ❓',
'actions.reset': 'RESET',
'actions.random': 'NOWA LOSOWA',
@@ -31,10 +33,29 @@ const messages = {
'custom.sizeError': 'Rozmiar musi być między 5 a 80!',
'custom.fillRate': 'Wypełnienie',
'custom.difficulty': 'Poziom trudności',
'image.title': 'IMPORTUJ OBRAZ',
'image.drop': 'Przeciągnij obraz tutaj',
'image.select': 'Wybierz plik',
'image.camera': 'Zrób zdjęcie',
'image.capture': 'Zrób zdjęcie',
'image.switch': 'Przełącz aparat',
'image.cameraError': 'Nie można uzyskać dostępu do kamery',
'image.change': 'Zmień obraz',
'image.size': 'Rozmiar siatki',
'image.threshold': 'Próg czerni',
'image.solvability': 'Rozwiązywalność',
'image.warning': 'Ten obraz może wymagać zgadywania!',
'image.difficulty': 'Trudność',
'image.calculating': 'Obliczanie...',
'image.calculatingSolvability': 'Obliczanie rozwiązywalności...',
'image.create': 'STWÓRZ NONOGRAM',
'difficulty.easy': 'Łatwy',
'difficulty.medium': 'Średni',
'difficulty.hard': 'Trudny',
'difficulty.harder': 'Trudniejszy',
'difficulty.hardest': 'Najtrudniejszy',
'difficulty.extreme': 'Ekstremalny',
'difficulty.unknown': 'Nieznany',
'win.title': 'GRATULACJE!',
'win.message': 'Rozwiązałeś zagadkę!',
'win.time': 'Czas:',
@@ -181,6 +202,8 @@ const messages = {
'level.medium': 'MEDIUM 10X10',
'level.hard': 'HARD 15X15',
'level.custom': 'CUSTOM',
'level.custom_random': 'CUSTOM RANDOM',
'level.custom_image': 'CUSTOM FROM IMAGE',
'level.guide': 'GUIDE ❓',
'actions.reset': 'RESET',
'actions.random': 'NEW RANDOM',

View File

@@ -93,6 +93,26 @@ export const usePuzzleStore = defineStore('puzzle', () => {
saveState();
}
function initFromImage(grid) {
stopTimer();
currentLevelId.value = 'custom_image';
size.value = grid.length;
solution.value = grid;
resetGrid();
isGameWon.value = false;
hasUsedGuide.value = false;
guideUsageCount.value = 0;
// Calculate density
const totalFilled = grid.flat().filter(c => c === 1).length;
currentDensity.value = totalFilled / (size.value * size.value);
elapsedTime.value = 0;
startTimer();
saveState();
}
function resetGrid() {
playerGrid.value = Array(size.value).fill().map(() => Array(size.value).fill(0));
moves.value = 0;
@@ -332,6 +352,7 @@ export const usePuzzleStore = defineStore('puzzle', () => {
progressPercentage,
initGame,
initCustomGame,
initFromImage,
toggleCell,
setCell,
resetGame,

View File

@@ -27,19 +27,20 @@ export function validateLine(line, targetHints) {
export function calculateHints(grid) {
if (!grid || grid.length === 0) return { rowHints: [], colHints: [] };
const size = grid.length;
const rows = grid.length;
const cols = grid[0].length;
const rowHints = [];
const colHints = [];
// Row Hints
for (let r = 0; r < size; r++) {
for (let r = 0; r < rows; r++) {
rowHints.push(calculateLineHints(grid[r]));
}
// Col Hints
for (let c = 0; c < size; c++) {
for (let c = 0; c < cols; c++) {
const col = [];
for (let r = 0; r < size; r++) {
for (let r = 0; r < rows; r++) {
col.push(grid[r][c]);
}
colHints.push(calculateLineHints(col));

View File

@@ -218,14 +218,15 @@ function canFitRest(line, startIndex, hints, hintIndex) {
* Solves the puzzle using logical iteration.
* @param {number[][]} rowHints
* @param {number[][]} colHints
* @returns {object} { solvedGrid: number[][], percentSolved: number }
* @param {number[][]} initialGrid - Optional starting state
* @returns {object} { grid: number[][], changed: boolean }
*/
export function solvePuzzle(rowHints, colHints) {
function solveLogically(rowHints, colHints, initialGrid) {
const rows = rowHints.length;
const cols = colHints.length;
// Initialize grid with -1
let grid = Array(rows).fill(null).map(() => Array(cols).fill(-1));
// Initialize grid with -1 if not provided
let grid = initialGrid ? initialGrid.map(row => [...row]) : Array(rows).fill(null).map(() => Array(cols).fill(-1));
let changed = true;
let iterations = 0;
@@ -238,41 +239,184 @@ export function solvePuzzle(rowHints, colHints) {
// Rows
for (let r = 0; r < rows; r++) {
const newLine = solveLine(grid[r], rowHints[r]);
if (newLine) {
if (!newLine) return { grid, contradiction: true }; // Contradiction found
for (let c = 0; c < cols; c++) {
if (grid[r][c] !== newLine[c]) {
// If we try to overwrite a known value with a different one -> Contradiction
if (grid[r][c] !== -1 && grid[r][c] !== newLine[c]) return { grid, contradiction: true };
grid[r][c] = newLine[c];
changed = true;
}
}
}
}
// Cols
for (let c = 0; c < cols; c++) {
const currentCol = grid.map(row => row[c]);
const newCol = solveLine(currentCol, colHints[c]);
if (newCol) {
if (!newCol) return { grid, contradiction: true }; // Contradiction found
for (let r = 0; r < rows; r++) {
if (grid[r][c] !== newCol[r]) {
if (grid[r][c] !== -1 && grid[r][c] !== newCol[r]) return { grid, contradiction: true };
grid[r][c] = newCol[r];
changed = true;
}
}
}
}
return { grid, changed: iterations > 1, iterations, contradiction: false };
}
/**
* Main solver function that attempts to solve the puzzle using logic and lookahead.
* @param {number[][]} rowHints
* @param {number[][]} colHints
* @param {function} onProgress - Optional callback for progress reporting (percent)
* @returns {object} result
*/
export function solvePuzzle(rowHints, colHints, onProgress) {
const rows = rowHints.length;
const cols = colHints.length;
const totalCells = rows * cols;
// 1. Basic Logical Solve
let { grid, iterations, contradiction } = solveLogically(rowHints, colHints);
// Count solved
let solvedCount = 0;
grid.forEach(r => r.forEach(c => { if(c !== -1) solvedCount++; }));
let percentSolved = (solvedCount / totalCells) * 100;
if (onProgress) onProgress(Math.floor(percentSolved));
// Difficulty calculation
// Base: complexity of grid
let difficultyScore = 0;
// If simple logic failed to solve completely, try Lookahead (Smash)
let lookaheadUsed = false;
if (percentSolved < 100 && !contradiction) {
// Lookahead loop
// Find an unknown cell, try 0 and 1. If one leads to contradiction, the other is true.
let progress = true;
while (progress && percentSolved < 100) {
progress = false;
// Find unknown cells (optimize: sort by most constrained?)
// For now, just scan.
let candidates = [];
for(let r=0; r<rows; r++) {
for(let c=0; c<cols; c++) {
if (grid[r][c] === -1) candidates.push({r, c});
}
}
// Calculate solved %
let solvedCount = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] !== -1) solvedCount++;
// Limit candidates for performance (e.g., first 50 or heuristic)
// But we need to solve it...
// Let's try top 20 candidates? Or all?
// "Parallel web workers" allows us to be heavier, but 80x80 is 6400 cells.
// We can't try all 6400 in every pass.
// Heuristic: pick cells in rows/cols that are nearly full.
let checkedCount = 0;
const totalCandidates = candidates.length;
let lastReportedPercent = -1;
for (const {r, c} of candidates) {
checkedCount++;
// Report progress inside the heavy loop
if (onProgress) {
const currentScanPercent = Math.floor((checkedCount / totalCandidates) * 100);
// Report every 1% change or at least every 10 items to avoid flooding but keep it responsive
if (currentScanPercent > lastReportedPercent || checkedCount % 10 === 0) {
lastReportedPercent = currentScanPercent;
onProgress(currentScanPercent);
}
}
// Try assuming 1
// We need to clone the grid for simulation
const gridCopy1 = grid.map(row => [...row]);
gridCopy1[r][c] = 1;
const res1 = solveLogically(rowHints, colHints, gridCopy1);
// Try assuming 0
const gridCopy0 = grid.map(row => [...row]);
gridCopy0[r][c] = 0;
const res0 = solveLogically(rowHints, colHints, gridCopy0);
let deduced = null;
if (res1.contradiction && !res0.contradiction) {
deduced = 0; // Must be 0
} else if (!res1.contradiction && res0.contradiction) {
deduced = 1; // Must be 1
}
if (deduced !== null) {
grid[r][c] = deduced;
progress = true;
lookaheadUsed = true;
difficultyScore += 5; // Penalty for requiring lookahead
// Run logic again to propagate this new info
const updated = solveLogically(rowHints, colHints, grid);
if (updated.contradiction) break; // Should not happen if logic is sound
grid = updated.grid;
break; // Restart loop to use new info
}
}
// Recalculate percent (this is for loop exit condition)
solvedCount = 0;
grid.forEach(row => row.forEach(c => { if(c !== -1) solvedCount++; }));
percentSolved = (solvedCount / totalCells) * 100;
// Note: we don't report percentSolved here because we want the spinner to show SCAN progress (0-100% of current pass)
// If we reported percentSolved, the user might see the spinner jump from 100% (scan done) to 5% (solved amount), which is confusing.
}
}
// Final Difficulty Calculation
// Factors:
// 1. Size (rows * cols)
// 2. Iterations (how many passes of line logic)
// 3. Lookahead (did we need it?)
const effectiveSize = Math.sqrt(rows * cols);
// iterations is usually 2-20.
// difficultyScore accumulates lookahead steps.
// Normalize iterations
const iterScore = Math.min(20, iterations) * 2;
// Base difficulty
let totalScore = effectiveSize + iterScore + difficultyScore;
// If not fully solved, massive penalty
if (percentSolved < 100) {
// Unsolvable by logic+lookahead
// This is "Extreme" or "Guessing Required"
totalScore = 100; // Cap at max
} else {
// Solved
// Normalize score 0-100 (approximately)
// Max theoretical "normal" score ~ 80 (size 80) + 40 (iter) + 20 (lookahead) = 140?
// Let's scale it.
totalScore = Math.min(100, totalScore);
}
return {
solvedGrid: grid,
percentSolved: (solvedCount / (rows * cols)) * 100
percentSolved,
difficultyScore: totalScore,
lookaheadUsed,
iterations
};
}

99
src/utils/workerPool.js Normal file
View File

@@ -0,0 +1,99 @@
import SolverWorker from '../workers/solver.worker.js?worker';
class WorkerPool {
constructor() {
this.workers = [];
this.queue = [];
this.active = 0;
this.poolSize = navigator.hardwareConcurrency || 4;
for (let i = 0; i < this.poolSize; i++) {
const worker = new SolverWorker();
worker.onmessage = (e) => this.handleWorkerMessage(worker, e);
worker.onerror = (e) => this.handleWorkerError(worker, e);
this.workers.push({ worker, busy: false, id: i });
}
}
run(taskData, onProgress) {
return new Promise((resolve, reject) => {
const task = { data: taskData, resolve, reject, onProgress };
const freeWorker = this.workers.find(w => !w.busy);
if (freeWorker) {
this.execute(freeWorker, task);
} else {
this.queue.push(task);
}
});
}
execute(workerObj, task) {
workerObj.busy = true;
workerObj.currentTask = task;
this.active++;
workerObj.worker.postMessage(task.data);
}
handleWorkerMessage(worker, e) {
const workerObj = this.workers.find(w => w.worker === worker);
if (workerObj && workerObj.currentTask) {
if (e.data.type === 'progress') {
if (workerObj.currentTask.onProgress) {
workerObj.currentTask.onProgress(e.data.percent);
}
return; // Don't resolve yet
}
workerObj.currentTask.resolve(e.data);
workerObj.currentTask = null;
workerObj.busy = false;
this.active--;
this.processQueue();
}
}
handleWorkerError(worker, e) {
const workerObj = this.workers.find(w => w.worker === worker);
if (workerObj && workerObj.currentTask) {
workerObj.currentTask.reject(e);
workerObj.currentTask = null;
workerObj.busy = false;
this.active--;
this.processQueue();
}
}
processQueue() {
if (this.queue.length > 0) {
const freeWorker = this.workers.find(w => !w.busy);
if (freeWorker) {
const task = this.queue.shift();
this.execute(freeWorker, task);
}
}
}
clearQueue() {
this.queue.forEach(task => {
task.reject(new Error('Cancelled'));
});
this.queue = [];
}
terminate() {
this.workers.forEach(w => w.worker.terminate());
this.workers = [];
this.queue = [];
}
}
// Singleton instance
let poolInstance = null;
export const getWorkerPool = () => {
if (!poolInstance) {
poolInstance = new WorkerPool();
}
return poolInstance;
};

View File

@@ -0,0 +1,61 @@
import { calculateHints } from '../utils/puzzleUtils';
import { solvePuzzle } from '../utils/solver';
self.onmessage = (e) => {
const { id, grid } = e.data;
try {
if (!grid || grid.length === 0) {
self.postMessage({ id, error: 'Empty grid' });
return;
}
const rows = grid.length;
const cols = grid[0].length;
const size = Math.max(rows, cols);
const density = grid.flat().filter(c => c === 1).length / (rows * cols);
// 1. Calculate Hints
const { rowHints, colHints } = calculateHints(grid);
// 2. Run Solver (Logic + Lookahead)
const onProgress = (percent) => {
self.postMessage({
id,
type: 'progress',
percent
});
};
const { percentSolved, difficultyScore, lookaheadUsed } = solvePuzzle(rowHints, colHints, onProgress);
// 3. Determine Level
let value = difficultyScore;
let level;
if (percentSolved < 100) {
level = 'extreme'; // Unsolvable by logic+lookahead
} else {
if (value < 25) level = 'easy';
else if (value < 50) level = 'medium';
else if (value < 75) level = 'hard';
else level = 'extreme';
}
// Add specific note if lookahead was needed?
// UI doesn't have a field for that, but we can encode it in difficultyLabel if needed.
// For now, standard levels are fine.
self.postMessage({
id,
solvability: Math.floor(percentSolved),
difficulty: Math.round(value),
difficultyLabel: level,
rows,
cols
});
} catch (err) {
self.postMessage({ id, error: err.message });
}
};