diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..be907c4 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,36 @@ +name: pages +on: + push: { branches: [main] } + workflow_dispatch: + +permissions: + contents: read + +concurrency: { group: pages, cancel-in-progress: true } + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { persist-credentials: false } + - uses: actions/setup-node@v4 + with: { node-version: '22' } + # No install: the demo vendors its coastline and imports the library + # in place, so the build is a copy with a size budget. + - run: npm run build:demo + - uses: actions/upload-pages-artifact@v3 + with: { path: _site } + deploy: + needs: build + runs-on: ubuntu-latest + # Only this job publishes, so only this job gets the write scopes. + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 2d98f97..dbd1145 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ *.tgz +_site/ diff --git a/README.md b/README.md index 0d0be40..65a6d25 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # coast-wright +**[One map, fourteen projections.][demo]** Pick one and watch the world +redraw. + +[![The same coastline three ways: a Mollweide oval, an orthographic globe over Fiji, an azimuthal equidistant disc from the pole](demo/gallery.svg)][demo] + +Same coastline, same drawing call, fourteen projections — the [demo][demo] +prints the few lines of arithmetic that did change next to the map, and the +boat drags across the date line and over the pole without the map tearing. + > **Alpha.** The code is lifted from a shipping plugin and its tests came with > it, but the format it reads is still settling. See > [the spec's change policy](https://github.com/mark-brannan/portolani/blob/main/docs/portolano-format.md#8-changes). @@ -30,7 +39,9 @@ line straight across the map. So does a ring passing behind a window centred anywhere but Greenwich — which is why `limn` tests each segment against `lonCenter`, the longitude your projection measures from, rather than against the dateline. A dateline-only guard looks correct until somebody centres the -map on their own boat. +map on their own boat. And a projection that does not wrap has a seam of its +own: a `visible` predicate lets it refuse the far hemisphere of a globe, or +the antipode an azimuthal chart divides by zero on, instead. **The projection.** `limn` takes your `x` and `y` functions and assumes nothing else. Equirectangular, azimuthal over a pole, a band around a vessel — @@ -54,7 +65,11 @@ not infer holes from it. ### `limn(ctx, rings, x, y, options)` -Strokes rings onto a canvas 2D context. +Strokes rings onto a canvas 2D context. `x` and `y` each receive the other +coordinate as a second argument — `x(lon, lat)` and `y(lat, lon)` — because +outside the cylindrical family neither output is computable from one +coordinate alone. A function that ignores the second argument keeps working +unchanged. | Option | Default | | | --- | --- | --- | @@ -62,6 +77,7 @@ Strokes rings onto a canvas 2D context. | `alpha` | `0.45` | Coastline under data wants to stay under it. | | `width` | `1` | Line width in pixels. | | `lonCenter` | `0` | The longitude your projection measures from. Get this right or the seam guard guards the wrong place. | +| `visible` | — | `(lon, lat) => bool`, for projections whose seam is not a wrap. A refused point lifts the pen and is never even projected — orthographic hides the far hemisphere, azimuthal equidistant masks the antipode its arithmetic divides by zero on. The wrap guard keeps running alongside. | ### `decodeRing(encoded, precision)` → `[[lon, lat], …]` @@ -84,3 +100,4 @@ MIT. [spec]: https://github.com/mark-brannan/portolani/blob/main/docs/portolano-format.md [gen]: https://github.com/mark-brannan/portolani [cl]: https://github.com/mark-brannan/coastlines +[demo]: https://mark-brannan.github.io/coast-wright/ diff --git a/demo/coastline-50m.json b/demo/coastline-50m.json new file mode 100644 index 0000000..0d33a32 --- /dev/null +++ b/demo/coastline-50m.json @@ -0,0 +1 @@ +{"format":"portolano/1","kind":"lines","encoding":{"type":"polyline","precision":2,"order":"lon,lat"},"bounds":[-180,-85.19,180,83.59],"counts":{"shapes":507,"rings":507,"points":11166},"digest":"sha256:66983549f2b938e61a0fbad5c1c9c7b3bb61cd34071ca1f8a5230a001d030258","provenance":{"generator":{"name":"portolani","version":"0.1.0"},"source":{"id":"ne_50m_coastline","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_50m_coastline.geojson","ref":"v5.1.2","sha256":"sha256:271f1c4c1908312bac6b29d158ea1356544beafc129f260005300913aa5ea283","bytes":1640858,"license":"public domain","attribution":"Made with Natural Earth. Free vector and raster map data @ naturalearthdata.com"},"options":{"kind":"lines","tolerance":0.1,"precision":2,"minExtent":0.5,"bbox":null}},"geometry":["_db@|cBzG|AN`@i@d@aBu@qCD?k@fAl@@UwA}A","yhXtdE`M|BvGFnInCpCrDnGLrC_@vHZnAnAlBTnEpBjGKhBe@rAiAvAQ^y@CiBiAV_AyACkHzBaFj@{GhCsEZqChDiF[JG_@k@~As@KlAsDs@l@Az@YW[|@m@E@wA|CyGMqBu@yAN{Cw@kC_@UC`CqBcCoKiFkCLkG{AeAs@aBFyGwAcAw@aBgDgBiAf@oBUqAmCkCuBvEEaBo@PGg@jAuAWu@q@^c@WqCZhASq@uA\\RVi@Ku@q@k@iA`@j@w@g@_@_AVb@qAoAm@Ik@Et@_@KM\\YYEeBi@b@[[a@ZaAsAoBx@sCnCXrBc@ELs@oAu@_BT_@v@Iy@gAt@Ay@i@C|AwA{AcDsAu@PcAyA}@?k@q@X?e@sA{@]h@aDQ_@T{@c@F}AhDeA[Wg@Xa@QeAx@w@Sm@z@uFbA}@M{@h@qDgAj@jA}@CMl@c@@A{@u@a@qAlApAtANxA`@QfA^C|BjBdDG^gD`CqAVOf@wDbAmB`BwCb@o@tA}ChA}AO_Ai@sBkGk@iE^{F[kD_Ag@n@mAc@iAQLi@{EiAo@{@vBEdB}@XLdAy@bAgAtIg@TeBm@cDlC[dH_BbBk@pCR|B{@v@ItAwCpAw@A_AlAkEfBWd@f@RyCfEq@jDu@TIkAgBlAOe@k@xEeEbCwEdGAr@o@t@FtFcBfHtBxJPlD|AhEbCzAfBfC@dApApBLdBvBbDt@rIxAn@~H^jEpC~BLW^YIL|@n@w@j@D\\m@lAY[]J]fBr@n@_@e@?i@q@l@e@`Bn@u@L`FxBvEuAnEElE{AxBaCOuBXy@vC}C_AD@m@vCr@bAEaAuBrAiDnA`E`DRY_AkAASuCwAsBNkA[Uh@eBAn@lB`D`DnAjB|BO~@hDmAaAAl@{BbBkCrAQf@sAYg@vAw@lE}@lBHtEeBxHX","a{a@|mBjAwApB\\hAxAi@n@kCReBa@RaA","kr_@`iC~FcD|AcB`F}B}@vBmD|CqHhDg@KDm@","a}WNNyDd@oAi@uBqAgA\\hAWbBjAlAq@TcAqB}Aa@AzAvAjA_CdBbEk@KhCyA~BjAc@fA}B","ovLrqHzCf@XOm@KH]jBTQk@Zg@l@pAUPR`Bi@H_@a@aE`@e@WnBY_CCa@m@n@[","oiKayNcEEi@c@aBCeBRY`@|Kl@hDWwB]","ueJ{sNjAaAaMXtJf@","d~CkyLG[z@WpD^fEErArAkJFmDo@VQ","gk^p{@`Au@rCMo@vA_DZa@MZa@","shVebANt@tJ`JXbAqFqDeAcCqA[cBwAd@iDf@vB","k`WcgAh@RH_@vDq@c@h@\\nFcDkAeA}ABq@","}bWgcAhBD^lAGpAzAt@uBvCaAi@^uBeAsE","omWk`At@uDx@J|@m@WzBi@GYb@@xBq@ZFcA]b@Ku@","epWeeA`@kBOsAz@cA|DG_@rAmBlANf@a@t@aCn@Tc@","gvVgoAGy@~@{@hDGyCrFu@IQkB","{l]zf@pCwCfAc@E`BwAjA[pAqBNPsA","k{\\tXdEkDnFgCN`@qHfDmAlBk@jCo@_BTo@","ibZdm@hCb@tAbDaCBaBeAg@gAj@}@","ipXjTlAuArDg@|@Z`EK~@dAGx@i@eAmAx@cAk@yBj@GWaA?aEhBFwA","wyWzSdAu@zCR]pAeBn@kBe@Ly@","s_W|q@d@CK`@hB`AdAa@hBVdDcArCb@Jv@S^{IFiF{@_@yA","kqVz|@vCwBbEj@Yd@eBJgDfBgAc@Ri@","_gVzs@T_@~BJhAu@\\d@wAhAnAJn@w@pAK~@^LtAy@ZgGuAId@oAENQkAMPi@","ovUfr@bAn@Cz@h@PoCJ[cB`Ae@","eqUhr@nA[fCRuBnAOv@oBwA\\g@","ywSjNzA_Ed@DBf@Tg@XF~@nAaCh@i@~BeCp@Ja@i@aAxAU","onCimJzETWTRX}@Td@j@iAy@}Dm@fA_@","ovC{eN~EQaA_@`AQbD@r@eAhJR{GnBzCdAc@XgIUr@l@iAFcL_BbC[","ccKmxNzMDtAt@gAZwG@mEg@]a@XO","ydHovNkCi@cEHjGYhNn@wF^mDU","q`IswNhGc@nDR\\z@lFAzFl@}If@uBKz@[k@MoP_Ap@I","urHmqLbD^PpAuBf@qGeATa@zDq@","mxJgtLjGcBrBd@_BjAmBBk@^oCCHm@","o}LkiMbBa@|CLl@fAuIQd@a@","uaQ_sNnHBqGz@oGk@pFS","o{T}pMf@d@jA@_Gp@yB{@dG]","opZ_oMzDJOb@aANcBOEo@","qbDc{EjARJm@zGAf@[KS~@JTWd@n@Qb@cDNcAl@qGQ]{@\\R","{wCmmFb@Mb@yApB[j@k@vA^iF~Aa@t@aAZI[XE","euEi}EbCj@dCGJh@rBRCXYj@oB^iCoAeACT{@{BiAx@P","wtB{hJjCbADx@c@d@Z^wC_BR}@u@Yn@K","mjAsvICi@_ASd@c@eAi@AkAjB@`@NHt@d@s@`Cn@}@fBgA@i@rAe@IC_@","mbAqwIZ_B|CPYfA_DD","gSowFNi@|CnAkCt@mAoAj@K","nqV|kMhMObA`@qCX~@`AaCRaGMAa@oCa@xAo@","dzW|jMCX}BIiHdBeFN[c@rCcAzFJkBY`DgAnFA^\\g@Z","l`LppNjEKiHhBcDYNc@tA]zCA","`eJhqKVMe@ODWl@[hAPCq@vAV`@d@y@AYZv@LQJwD?","piKprK\\?Iq@r@KzCvAiBXsDs@x@?","xnMfzLcCg@hD[d@h@fAg@b@^rGn@aAZiKi@","xrMruLqAm@lBk@bDp@_Ef@","hnRdaM`Bg@`Lp@}X~AeLBDYcBGI_@^e@`G^kD_Ar@K`B@~AbAl@QUSTWtBn@B{@tAK|Et@","bvMbfMdAJyA`@zB@d@ZoHpAg@]z@c@mAaAzEY","rmLjfLuDu@p@SmBk@v@Ws@oA|B`@rCvBFb@k@^","~cb@c~LwH?{D~@jIv@fDI","dhJvaI`@K]K`@[v@F@\\n@QO_@`BIZrAdBn@Nf@q@b@aA[X]mBND_@qD{@","j_F|sI`@?r@{@~@@H]fGSaF`A_DhBy@URi@","dnM~mGcAACe@}@k@dAeA_AWJqBhBO`AxGw@X","hsJ~aI{@q@lDHtAW_Av@`ALcAJnCx@yAX{DeB","|sMnvHM}@k@UZt@Mn@k@NQ]L}E`AC|@rAWVjAP_@Xn@z@gBH","viMxvGqA@i@u@~A_@ZrA","fnKkuHgDvAkGNtG}BvGWyBl@","~tJc~GDk@tA]tAdAu@An@f@lABk@m@n@F{BaA_@cB^o@jAh@|BjDs@hAiDOmBy@","f_`@}jKlBCnDaAvBTzB[`@f@{@f@eEIiDrA_EeAv@G","dj_@evJRw@xAQzFd@aGxAoB]FW","l{^iuIv@u@r@AdCZrAhA}@ZgBk@eEId@S","d|MmpBnHiA|Al@qBpAqB`@eAs@_CJZo@","dyK{qB|G[f@^WlAqDBsAUg@_A","pw\\igJBc@zAJGY|@MJ`@t@LpAQi@^\\B]`AtAcAtBv@iAxA_@Qf@Q[GyA\\v@`@SHaAe@PQqE{@r@]iBHe@Yd@M","`v\\sjJg@YjC[`@DMZxBd@yARwCe@","vqXi{It@m@dAXd@xBgAe@EXcA@O{A","|zXkvIyAh@G_B`Cs@WWcATtAsAbBe@Rm@nA?M`@h@d@iAOYVvAXaCf@Md@^LaClA","jdYicJ{@h@ObAwAG`@aAiA\\Ty@tDG","~iYwdJpBy@hAp@At@gAo@q@j@Xh@yAZR^{@z@KcBr@gB","beYceJe@IDm@hAyA_BpAZq@t@u@vACn@e@uAlDRlAaBi@","`oY{eJYq@}Bd@Ho@fAIiAICg@nASr@XC_@f@WnC`@Il@yCxA","xcXwgIQ|@a@m@FsA~AcA\\~@w@x@]QB^","dzXmkImAWy@eDzATJdAbADiAm@Rg@|COD~@c@v@}Aj@~@DoAV","nrXkeI|AgAYWdBc@cAGVw@~CJqAZVLQ\\uCjB{@B","ncKqaH`ELlB[RT|@aASw@pAhAeD~AgAHm@_@NRa@RXHw@Rs@EHg@eBk@ZM","raMk_G~AR}@m@bGl@zAv@gDAaGqAf@F","gv\\xYxBQOfCbANvBlBlB@PeBr@dBvGIPf@eGzBcDEwCcAy@iAoBQL{Aq@I[k@AmAh@I","ggWzx@`BdALjC}C_@cEmDwEiAaDaB`AS|Hr@pCjBxAZ","uz[bmGZg@]Ql@OVb@\\o@HtAp@KOVh@`AdDKHa@k@KpAOhBkCL}@u@r@KSz@e@xA{CJuC_I`BiB@[c@{A@gBk@q@LWpGXq@x@vBKtBd@J","ubRwwN|KgB~B@`Lv@~Ah@eCAzItAuMdAuSm@o@OlAk@OMwC]","_mRqmN~A_@_CES}@w@]bHu@|Dx@iAs@tA]fOL~JvBeFRqB|@iPr@yMYpAa@","epSskNfBCdD}@tG|@iC{AjCYj@JMTxBStBn@d@j@STjBFnApApCz@oBNmGm@cU]qB_@^_A","aa[cyMvCD~G}@bBb@Hr@v@LpJqBjFh@XNi@\\vA@CnAbABcFhBiEZsB{@uBTwI[_B`@cC]xDsATu@k@E}BCn@r@Wb@oFl@kGaBlIaA","{~ZyjMxGcAhDFjClAhCHwVb@\\_A","yl\\wtMbLO^a@dIFr@i@lAjAcQtAqH]o@o@X?","sia@~dFs@~CqCx@qBReCwAiBZt@jDx@`@LfBlAa@vA\\ZbAa@Tt@`BjDpEdCnAZe@x@?@g@l@LgBeC?eBvG}COc@wCeAaAeEbAkC[Vg@[xA[x@yAi@T@gAdAWXi@s@rAVD~BcDk@k@~@Vd@m@Me@~AoBaAENT_AvAa@[aCz@G^c@Is@jBb@@oAdBNhBsB\\_@l@UK`@cCkAz@","qw`@nlGaA[kEiF`@kA{@aA`AZs@k@z@Ef@v@]eAdDfAXuAx@c@o@]TEnBt@~@zCzAp@xAtEL[l@|@vANSLtFvBAX`D`@dBrAI^VK|@`@@d@JYn@`@QNl@Fg@`@LVx@SZl@s@Ad@Vg@J`BRB\\uA?h@`@WRmCO{@j@eAAe@p@eGKaCoBwAWNe@qA_CTY]?QaC{DgAZg@s@`@c@[CTqBD?e@jBk@gAcA","evWen@j@yDl@Wc@o@XwAnC_BEtCx@CL^t@SZpA`AGf@hAnAb@IkArAu@fBjBdCh@bAlDo@X_BaDk@l@s@a@y@x@SoAc@AkBrAj@pAQtBiDfB{@e@W|A_AkANaBh@k@}@qBYMiAjE?kBgAaAGiA","i{V}pBZpBeAvBfAnDjBp@f@bCeBxF{@P@k@uAa@{@Xa@vAg@QCk@kBr@|@\\o@jBeALN|Ab@WMm@~BW\\oAnB}AXBg@`BLh@bDuCvAx@bBm@f@NBmA_A{AfAm@AnA\\DfAoA|@yGmAh@m@UDgHo@mDcBUiD`Au@i@Mt@","cnZhv@fCgC[q@d@b@jBPZi@fAr@g@aCbAcAuAKrBq@s@OrAgBLcB`@E[Yp@DDo@zCgBbGsAzCOfBiAi@[vDWx@cA_@uA~@fAV`B\\^bA?^yCzCcBs@UcBT{AeAeBb@k@gB`IZx@q@PuAxD[s@oByFeB_EjAmBFo@tB\\fAOtBu@fBCq@]GMlA[CIz@g@ZoA?sDwEgC_@Iu@}CgAwJlDeDRY^wABoM~CoBjAqABaGdEBrBmIpBkAdAAjAhCB^h@{@hBmD~ByA|D{BCK|AuCd@p@VUf@cEl@nALg@h@`AXpB{@jKw@tCgCJaA`AUvBmDnHeBhAd@`B{@y@~ArAJQf@xEV|@WsDt@u@x@@f@nChArA_@zEH|AiA","qkV~AHmAcA{Cm@y@}@XiA{AuJzAiE@sAc@oCgCo@f@nCzDx@b@hCNhBe@bNGlAr@b@|AIfAyB`D_BEuAeBmARoFgAs@h@Hj@~AS`CbCvBbAx@CyDlEUx@\\rAwAzA_@CC`AvC\\Lx@xAMRc@YaBpCqB_@yCnAOlAv@c@vC^~G]vAlCRfAkAq@qDVmDpBMZwC}@o@m@mB@gBg@cBg@m@Yb@LkA","irT|q@}Kr@qBWmGvAh@_A@_C|@[rEDj@Up@{BfEw@fAFh@q@l@D|@~AbFOxBOdAcBbCMbAo@v@Oj@f@tCe@vBlDTWXV_Gt@Tr@YNoIlAsEOiGxA","cuRkLK[a@BuApBkBp@iAvB_Cr@B^zAf@}Cy@o@Vi@bAfAn@AlAw@|@_C\\_@zCaAt@f@dB_Am@uA@_CpCf@bBIvGZ`DnAu@t@f@vAm@FjAlQsOvCeEvBaHzBiDdB{@nBmHrDcBTaBzBqChBo@jDgDhA{BCw@oAIkDjAsEBkC~CK~@{GtDqCvDuA|@Le@","w{TeQbBPb@vAl@?Kn@n@rBe@^nGgAl@u@p@TrA|CBxC_A`AX~Ao@d@Nh@mAP{@`ABtB{@|E_CKE^wCc@a@bCsAm@_B?u@mA}@~@u@ISr@}Bk@?f@e@K_@|C}FqB{@}AT]c@O@eAm@[Bg@j@MUgAZ?}AyA@y@a@b@aCyAFiEwAaCHi@{Bx@yAa@lFqEw@y@bByBGYr@Y?o@x@e@oCMv@wAg@i@}@PkBYfAyAM_@mDUYu@tDwAjAR]]Ve@bB`@[WA}AfBcBbAfA@kArDrGv@@b@d@SfAjA\\ZWdEvAPbAnDlEvDz@","kmJ}nMPu@eB@g@g@uANO[uC]j@UwE{@qi@aEyCuAxAk@~DOdR|BjSRr@h@zKj@bDd@xA~@xCAGT|@ZvCCiC^vD`AoBX~CNjCzAnDd@kBrAmCScI\\uDgAl@c@yALUOR_@iDk@","kaImaMoEm@{@iAhAScD]b@c@a@W_Ha@uH^l@rAnCr@W`Aj@v@eFdDkFjA~AZpDW{@VXD`R}@m@GKc@aBGtCi@Hg@lI@r@{@]o@","qxZgrIcA|@Az@r@r@pBBAvBf@fBKhBaBfBZtBMrEt@bD}@rCj@rAOxAh@hCq@hBcB{CkCZ]hB]aAPmAt@GbC}DAaAiBaF}DIcCxBpDgI|BkIYkEnAeDKeAx@w@fAZ","qj[koGTc@u@qB`CnA~Dg@jJsGdAp@a@zClAnFl@RtCWGdAjBz@HhAu@hARlA[ReBoAaB@bD_B]u@eBj@kDo@uGrBe@sAkB{A}DWuDmA`BPXw@","c_YkuEdDC^~ApAi@bDLDw@{A]yH{EsE?mFo@_@j@}AC{@k@DeA}B{B[_C_B[rAnAWv@m@PuE}AqB{B}As@wA{Cs@sCLo@p@Ku@cAPcAgAq@KeAy@?WhAiAIWaAvALQcAqBNDfCiAtAc@jDdBlEfAJ\\d@GnEtAnD{@dBpAh@T`AdBt@@mAu@y@t@Mb@p@Er@tAYpAbCKqAb@QhBfB`FBk@e@pAFL_At@hA_AfAfBZpAzBj@Lp@K~@{ADw@w@i@B]pDShGxA","{hYasEJ{@hCWdATXt@bAFd@a@z@pAxBdAkAQQdBiA`@sAyBcA]mBr@gBgC","{wXglENqAfAC_@aAhB?`A_AzFfCqAlA|@i@Q`Bw@g@y@L@]\\?KaA}@dAH`@[FxAhBY`B`@d@wAl@DkAk@]PhCqAo@Dc@u@F_AuE_AiA","}xVs{ClAk@~Ap@tDrGHnBcC`FSDUqByAeCeByJ","qtTg{B~@s@J^rAMtD`@tBrBEvCcD~@cBg@uAaAe@kBiAiAFk@","m~Nmm@zAgEbC{DzAuAt@AyA`AbAS|@vHDu@FT]lGo@fC{B`@wDyAk@}ADiB","otGn{BIaBh@eAz@kH_BoDC{AwIcBo@b@Li@]g@{Ay@_@h@@w@s@kAUKCz@y@iAe@Dd@{@m@qARm@[Yk@VkByAWuAXiA}AmAgCdEkBnMt@pB~@cBl@Jc@`ElAtCPzErLhg@xAfApBN`C`AlACvEkBhAkCIyCzAeFo@kE{@c@wB{F","ph^lnN|J_B~DMhEv@\\p@qBd@cQhAgJEvFeB","``LzhNtCCtRjBbDl@`AhA_D`@yFIGc@wAm@wNeD","tqGfjNa@s@|@a@zJcAdHAbIl@bFnB^`@[H`BxAeAJB`@zJ|BxEE~DzA?d@e@TmZQ_f@uBl@a@iCYY}@vBgChJUwGe@","zrJvsNx@COk@^SlCGzEv@Od@qAHvANb_@IsNzAaF_@}An@aLVuCk@SmAqA[b@O","zwLbeMiGYeCiA}@uAl@qExFoGhAADeA|@a@`H\\t@n@oAn@Z`B]ZwGTmCn@lCGjDz@eGj@Ap@dBc@jGf@dEOdAJiDh@tG@k@b@RHvCe@d@LGb@lCOv@t@sGbA{A]n@a@iDLeCw@_Ht@vEh@o@XaHCbCZtJQ~@`@iCj@mJA","|yKptIrHwBlDsC`AIKg@e@B`AmAj@UjAPn@c@hB`AvAMq@d@`At@q@f@cDGGTrCn@J`@_@j@{D~@r@ZtAs@bBFlAcAq@C\\gAbAp@Al@mBrAlAGR_@`Ff@}Gv@{CS_Cf@kDQuIh@oEWy@m@pBE","tpMk~BxAy@vCGWw@VWdFk@l@s@\\TVYm@EfAm@ZLpEqBtBGjB_BdDi@`HQfFj@jDdAn@n@E|@nBh@mAM?\\}A_@Ym@gBI_C}AsDBY`@hATm@b@qBRQ_@]f@mBIs@p@mDv@}B?o@jBiAx@qDVYl@zBlBwHYmDR[WETaEu@fAi@","{fD_tN~Ng@lF`@i@o@|@GnBL^dA~NuAVBg@XJRpBSgA`@f@FzGC{DRvBT}A`@yK@|E^{ARcPN`@NyFZqIi@wHeBbAq@","o`CujNeCMpDo@u@O^InCHpFg@[a@rAc@pCj@y@}@XS~Gq@z@Ft@`AgBjClH}C|Cb@Np@|Bm@xC?oGe@l@SxQRKn@}AlAyC_@SBd@VQVsANlE?cJlCiHa@h@OGe@k@GaAPgA[Xb@a@VoGe@`A^oCNxTlAqAr@sPYWR|Qx@iAv@qM`CgAON]u@_@RO}AeAgCc@[yAoBIKo@{By@oEK","o{@c~FxAwAbCpAdAKDr@w@fAJhA[NX|Bm@v@_AEQw@cBLO{Ea@m@X_A","_u@gfGOfAa@JNTkAh@iA{CRcDRGL`ArBZZd@Mn@","{~A{mFtHn@bBi@lA^`@]x@fAg@l@cIjBo@p@yBTe@iAf@oAiBaDx@H","tdIiqLi@U\\i@fKaBzCNC`@yAT|AAXn@{E`@_@LfBLuAZgIm@","jbWkoHNVbB_CdD}@jCaD|MgBtAVy@n@uBOBb@rAII~@yBA[r@i@KyCt@pAN@`@yBB@\\}@Jd@Ry@ZyBKWg@`AlAWTaHlAy@[Zo@","flLcuBzDk@mA]dBEj@iAlDk@pCAt@f@pEm@nBh@sCv@ThAuAbBpAZ~Gm@p@h@wBpAiAi@aHB{BtBiAyB}@Sw@XqFk@yBj@_AmAbAy@","jmHdArCe@jG?~@hB?~@g@Pb@Z_@zA}CP{@w@w@PKe@o@LmBaF","|W{fJkCY{GRQZnCdD`Dv@sAGk@\\hCj@tAMsC\\yAO{DzAuAlDeFrBXRaApAzCUiADoBdAQb@|@p@m@Rw@_@cB@wBh@Qv@^dAfA\\F`@vAL[^|AReET@b@zEnAvIWQNjCP@X~DWnAPv@lAdB]xDdAjBGeH}EqECmCiBjDdAzDc@[Qz@OdBTfAq@}EyAa@i@VkArBTqAaAeBa@cCHGa@}@V|@g@e@eCz@HrAuAmBmAvD`@jDMP^l@a@DYaBeBh@g@Km@q@Ar@UGUpAt@@c@k@_@jAh@b@|Bh@Du@wAVAQwAcAeBzAl@~Ae@oAYXIu@g@l@cAo@U^QMc@eBKp@e@Ma@aACE_AaKI^|@zCfAHTc@Dt@n@[?","rz@{aIrBJgBu@rCLuA_A|AIyBOZ]w@]iDYbFT{Aq@_@w@iAUfFg@D[oAIb@Y_AO`AIIo@l@KESmHBLUaBy@|BGkAi@Us@wBUUZR\\eAoAeAXj@b@qE_@qAnA^b@eABIb@d@UKv@rCv@_ArEtArB[R`C?xErAdAKK\\zA^lER{@c@","~cb@cnLaFl@aA^h@MKTuBXG^s@OpBm@{CdA_MjBWl@ZPgB@Pr@a@|@\\^sANUf@}@YAn@{@g@zAg@k@]N_@zA]uGB^VKTM]eCF`BUqFXiGxBXL{@GqBl@`Dd@Xp@zCa@oAn@VH`Ic@mBj@Nb@dADiAFO^jDf@u@JR^gBf@fBSB^n@Pj@MIe@nAb@fDgAlDSpAe@@k@l@o@`E[lGTPo@vAy@e@Bk@mAbAh@t@e@d@j@dACTv@wAjA`CxA","l{Aq}K`Af@sATXr@wAEMT\\RiDJb@`@g@|@p@Hd@p@dAAn@`AbXhEtH]x@YOWn@LzAg@rHTHq@eBFqCcAtBLSg@gAWfBRfAs@lGHh@WkLe@YWtCEeCq@rEYfE`@rBW{BAl@o@_DDjBS}@Er@[eA?`@Q]MiEt@@_@bB[}AK~BMm@S{A@kEnATJi@LJ^x@BiBxAaAiAeALQ{A]MoC`AU}@iBSmCxA^uAuCf@oAo@cC?Hq@y@MmCx@}D[f@F","ciBikGi@@jBOaAL","_jBolGrCQsCP","gxIauNr@LcBAn@K","mv_@rfNpCb@hDMu@b@{EKs@UHW","y{_@tbNdE?rCm@|@Pk@XpAZeB`AcCg@aFDeD_@rDY","qrRdxKVb@s@HmCa@jA]|AP","ktO~_L}@r@y@QvBa@","q{Qz{KrAXmCEx@S","}cShwKH[jAC_Cv@h@W","~m[|vMlAJuDZfBg@","|t[f_Nj@?A\\wDGlCU","vc\\r_N_DN~CO","h~[|~MzEE{ED","dsX`pMxDBcE`@i@Or@U","djVdnMeCm@vDM[b@p@PgAD","jvUtlMjEp@cCHuBs@l@G","x{W`pM~BOEZsB`@iA_@bAM","rc\\jbNvAGqCPx@I","nx^zdOfECuCr@yGMfFa@","~h\\r~M|C?}C?","tv[f{M{AHpCc@u@X","lu[l}MpALqB@^O","|i^||NlKw@hHNwTf@","zn\\raN`BHeFNbCY","`a]dsNjJg@kJf@","`d\\baNrBu@vDTkH^","b|]d_OpCa@uD`Aq@OtAO","|d@~vLhAHw@^u@Kb@]","eRzvL`B\\_CA\\[","eGrtLfA\\s@h@q@o@\\W","xNhyLnEMoB\\m@~@iCeAvAI","yXbwLo@Nq@YfAYd@HKX","mbBxrLbA\\uBd@}A_AnCC","yeDduLlAGdAd@KT{CHOQb@k@","t`EzqNhCe@~@R{Hv@aDShFs@h@`@","lsEnnNrHe@tEd@iO?","bPtwLEe@n@GlBh@wCB","v_CvnMCsBlB~@tD`@mDBeA~@aAFMa@b@W","vfEnpNrAMf@X{BK","b}GtyJyBZ`Ac@zABc@B","rjLxnNXT_FJgAm@lGJ","dyIt|JQb@}BYnCI","tyI~iKpBG|A`@YTcGWlAW","n{IjkK~AHw@Tg@_@","`fJzqKi@TeAo@nBX","~lJ~cKwDo@vCKvBt@wAD","jbKbpLtBW_BlB}@y@f@[","`cKlpKEQlAY`A|AkAC}@m@","d_JxiKy@]lBDs@V","~zJrfKsAG_@XiA[|DYdATeAL","jeJfnKo@GrBEcAL","lyJtwLtAPo@Tq@KJ[","jvPpeM`@a@nCJ}@x@\\x@{BWUeA","`oSzfM^HOZeBFtAm@","`nQ|cMjCb@gDGZ[","fbMzrLi@G|Aq@bBNwCh@","xmMrhMRRc@JuAs@dBR","z{KvzKi@Gi@y@`ABh@r@WH","tbLxeLtB@g@XmA[","imJk{NwC@vCA","{oIkvNkBF|BBQK","uyJyyNcEFlHLiBU","cfIitNXI_C[gEXlHJ","s|I_tNOc@{EBXp@|FEk@K","moJgsNuBOiAXrBLjAW","duMdoHlAPKiCk@?c@fALn@","diMxkIvGcBwA`AaFt@`@S","|iL~xIB[v@K]g@zGOTp@uCdAJ}@qALCh@cDd@r@w@","ntMvpH?]zAPQj@\\VgBw@","uqIcnAbCKh@d@oAh@}Cm@~@U","m}IsgD|@D?ZxAj@_CUaAq@h@E","{s_@d_BRyAt@j@n@gBe@dEaAIQe@","_z_@r_BIdBHeB","uw_@leBtBcBHd@g@LSnAcA_@","ue^ps@fAkA`EaB_BbB{DfBP]","yv]tl@jAsArBq@aCfCsAHTK","_uVsd@n@O^\\iB@XO","`qMtnHr@sCrAb@aAvBeAG","hoK`uIlCR{DSl@?","|cMtpIs@k@t@k@nAA`@e@HVdASnAZkBb@Sr@a@_@w@D\\p@wAO","jfLjxI_BBa@_@^[pE?g@l@gAH","fvLdwIZ_@jEC_Ex@g@U","dqMd`IBc@jBLZdAo@m@{AA","nsM~xHOYxASV`AaBS","zpMxcIv@cAx@^s@~A_@@]}@","|~L|qIiBZ[WB[hCe@x@Gh@`@s@p@w@M","xkM~xGt@iB{@_Ax@YTp@nANVb@gBfCq@@Wk@","|nM`|GNwAhApByAY","f_KghHOOfBh@Pf@o@EZCuAw@","uaU~i@f@BTn@mCNyBm@TWrD@","`t`@dsAxAHo@t@_A@TaA","bq`@huA`@RsBZpAo@","qya@huBhAd@iBS^Q","og^xfANPsBj@bB}@","qo^hz@tAiCKe@p@{@\\Bu@dDcBxA?W","iu^jbAb@aA`Dq@aBfBcBJ","yy]pt@x@oBhAf@MVs@KEb@{@V","tla@npGQCdCNy@DLd@UNe@Qd@q@q@C","_z_@tdHf@{@b@@Ch@v@dAoCe@NO","mgZneBz@LXt@kBm@TU","qvYvwAZY[w@XJRa@n@b@NdAmBA","knX~gA`@cAp@xAqBD\\[","otXteA`@W`Cd@p@a@[pAwA|@eBcABw@","abUv`Db@eAi@nBDi@","wc[twF`@i@HjBi@SAm@","u|[rzFg@Q@c@hAs@^ZcAlA","y}Yn~EnCa@|Cz@uDb@k@a@oA?B[","c}\\vzCNc@n@`DMl@q@kD","ifW}cA@o@`CbGJlBeCcEI}B","aiW}}@~@MdA~@IZq@N{A]P_A","{yVci@p@X]d@gAc@r@[","spW}}@DiAb@~@c@l@Ec@","oyVwmAA`B[kB\\H","aaWujAHb@iAu@gBxAZsAdCqAD|A","chWavAV]RRN~@[Xk@]Jq@","gx[tKrCKXb@gCDe@]","oo\\r}@bA}@c@jAu@RMm@b@J","s_]`gAbBi@c@n@_AE","wrYrElAaAdBOiA`@a@fAmAADU","ouYxIxFUwCt@cCQ`@M","enXfJTUrBb@mAd@y@QAa@","qpXxCpA\\uAKBQ","osX\\|A]pBh@yAPKZ]Sv@q@mAt@q@@Ck@","wbXgOjAz@Ot@_Ae@BkA","w}WhCf@ODy@n@BY|A{@LGe@","m~W~G~@Qf@x@wCBn@k@","ciYve@AaBn@u@z@t@YVd@l@y@t@g@FSa@","wgYtg@pA_AB~Bm@Bg@cA","i~Xbb@^hAy@eCXz@","a`Wf_@DaAc@KXkAj@l@f@nEkBaAZa@","c~Vd\\hARJxB}@GWeC","srWdJ|BH{DC|@E","mmWtIlCM\\j@Q\\qDYVa@","mbWvFd@\\Dk@z@@HhAy@q@C~@Sc@c@JS_@VS","}wW_[TMHhBi@u@He@","_pVbc@AfC@gC","cvXrk@vA`A\\hBk@?y@iAOaB","awWzn@tC?b@~@eCEcAi@NO","{aWp`AdBpAuBq@N_@","ujW~q@n@C\\p@yCK@YhAG","sdWdr@lAP^p@_AFiA{@ZO","wuU`U@w@p@fAMvAi@c@BcA","mdT}WZm@p@b@o@l@ZTk@@M{@","kaS_El@a@Rn@oBb@l@q@","gnSZz@_@Ph@oBP`@[","kcTzOlBYJ~BoBMIwA","ebReFtAmAv@TsClDUqAZc@","{rRnRr@aAu@jB@i@","qjR~Gf@aBp@BHx@wAfBg@GZ{@","cfRBBnB[_AVo@","syQwNfBiAb@JgC~ACa@","kdEk`HLM_Cj@pB]","akC{nJY`@}A[fAs@tB\\eAN","mfD_kNjAKwBNj@C","qtDelNfFJiJI`CA","oyHatNvC@qAReAU","g_I}rNz@ShDTmGHf@K","wcEusNmLUlLT","cfJs|NlKl@q@VyIHqBU^WkCIzEW","mtI}yNnBz@iJb@iJ_@bR_A","e|HqyNg@KrBAkAL","yiJ_zNfDRqCl@eEG|@i@pBO","_hKa}NsDGlIEyCL","_rJ_~NtFMj@TaHG","wrBqtNk@IrB@gAF","qiAyhNpGiBoBvAgEl@d@[","ckIg|LfA_@jBL{CfAWu@","apMahM|@TkB^_Ag@lBM","izMujMlCVmCW","q{MwjM`@FgCLdBU","}dN}dMnD|@wCDsCe@zB]","ioNcgMp@UhBn@mDXGg@XK","ufOmnM`AJ_D?|AK","q|OssMlAPkDI|AG","s_O}vMfBZ_BVo@Sf@_@","uxQ}{M~DReEP}@[bAI","avQ{`NrBN{DDeA_@lCH","coPebN~ANiAFUW","}|MwpNrA?sHZ~E[","cnN_yNn@TaHOpFE","wzPkzNzH@gGTs@W","cySqgN^D_G@~EG","wySchNbCFcCG","}{Tm}MeBFhCWc@N","omVghMvAF{BDb@M","arYqwM|Ao@p@zAcBCk@g@","yd\\{~MhEXiEY","gqYanM`C_@qCfA[Qj@U","_yYk~Lj@HsDBfCM","g{_@stLpBb@aFt@aAEc@m@tEe@","qc_@cqJ`Df@`A|AmFcAHaA","ew_@iuIt@Q_CdAhAs@","cp_@evI`As@E[hBBsDvBLk@","sn]i|HrAhAzARCh@gCi@c@}A","ct\\}cHZBSJ}AeAtAt@","gh\\s~GdBtA{E{BtBd@","aa\\m{G`Bl@nASjEbEwCsBaFcACe@","c}YiwIfAUdApAm@Os@h@aBiAt@K","wq[stGp@OdCvCWNeAwAcCcAh@B","q`X_gD\\h@d@Cr@vBk@eAwAw@J_@","_iX_qDvAl@i@VcA{@TI","gwWupEtBn@UTuAMk@a@`@U","{vVwdEhAa@aC~@v@]","kdQkrAGi@f@d@L`Fr@fAa@fAy@mERoAYoA","wuPmmCb@Ic@dB^r@}@QG_Ad@}@","bs@s{LvCx@_Fu@fAC","lg]wyBp@eAdCu@Ip@l@r@a@fCq@T{CaBRe@","vs]kcC`AIFVsBIh@C","rn]gaC`Be@TNy@bAoASPY","ly]eeCXm@|@T_@t@}A@d@_@","{~a@u`IhBa@_Dt@t@S","_{`@iiIbA]xBJyAd@cBS","lt`@oxJhBi@\\VgDd@^S","vw_@gmI`BCPj@|@HnAnAaGaC","vo_@anIy@_AtCEX\\{@^h@IV`@dCh@y@FgEaA","nz`@sdI|BCaEFbAC","p_a@sfIt@RYRdEf@kFWSe@\\Q","tna@}bIT_@r@lAgB]\\O","pra@_cIzAZaB?Wg@\\J","fua@}bIlBIs@r@y@i@","}jMljIbAIk@f@iAMp@O","mmDebFd@b@Er@cA_@[gA~@N","ydDysFb@i@dBb@m@Rg@S^Vw@Te@KH[","hgBwkElBIk@\\aAS","`eBkpDi@g@|Cn@q@`AaAiA","tvA{qDf@nAr@VoB]SwAf@L","cnGpgANo@LjAk@^L{@","yuFpe@b@yAXxA{@v@Aw@","{gJr|B^Sp@xA_ADQkA","{zItaCdAAIhAwALZuA","mwF~`@WiBf@BOdB","wu@mVz@z@Ad@y@Ge@qAd@G","nc@g`JxAQ]Vd@`@mBCJc@","vFayJ`@U^Tg@^x@HiAX@t@q@oBd@I","~Y{lIp@Dg@r@_Ac@t@U","rPsoJtA_@RViBF","xj@acKw@ZTa@c@DE[jBK_@f@","xi@mhJyAi@\\Ik@SU{@nBd@ITfA@m@r@j@Tm@C","vf@wdJaAFG^_Ae@|AO`@gAvAh@mAj@","m|BewJe@MhA]Fd@b@AY\\u@U","_iBydJlBfCC`AiCwE^L","_tAesIKY~@[b@z@g@\\eAET]","{gAuuIvBUB`@qCPT]","|oDcqFiBb@hBc@","|~CmkFr@KqA`@s@WpA@","myAqvGZuA]jB@U","|_IqsLc@`AaBaAdC?","dnI}zLe@QZc@jA`@aAR","~yIqdMcBk@lFLiC\\","xcIszL`@RiB@fAU","|`Hu{JnA\\qARa@w@b@D","`iWmuHeA^dBq@_@P","zvMq~EF`BZLa@MAaB","b_R_kDNv@_AsBn@z@","t_R_iDi@xEh@yE","pcNstCPw@ZJKYz@Lm@|Ag@@Gg@","lcNyfD{@fAd@|A]Vc@}BzAiApA?uAH","jkM{aCcBG]_AdCf@C^","|lMalChAlA}@c@WiAJ^","b|Mi}ChAm@LX{B|@^z@]`@GaBh@e@","ptMmpCPRiAz@|A_De@nA","`wMywCf@o@_ArAPXg@Al@{@","xhNkxCaAp@{@i@?i@rAaBh@bC","rtNcjDQb@t@aCc@|A","vcOkgC|@WCdAh@?k@\\}@OD{@","|gNoeD}@IfCUTf@v@Qq@d@eB[","v{JwbApCVa@VB`ApAf@sDKQmC","`oKedAlAPqB?Je@VR","xq[sxJ`ABSXcB[t@A","zu[axJ^EhChBiAK_BwA","f|XauIK_@j@@r@aA`AQkBjBi@D","rcYs`JO]t@a@~@Jy@jATjAo@IQyA","jzXc`J|@hAoBd@K{@|@s@","vkXgmI~AKuBfA_@Gt@s@","h_IjBz@TlA~CcCuAE_B","hxHm@h@d@sAJk@o@tAA","ryHkADi@p@jBc@JSmA","nwPvBdAiCb@CRV_@@}@jBfAfAqBBUe@^]","nbUcvDlAu@uAjBFu@","~{T_zC?aBZbBk@r@Nu@","{tLg_LtBWiAl@k@U","op^erLvAj@Kz@_ATFRSM`@oAs@YPY","jfKs{Ko@d@v@d@dBCx@e@GZl@F}@HJbBh@d@rCo@Om@x@v@fD_C}DoBlBn@dDNa@g@nALxDcB~@JOr@`C_@kCvA`@R|E_Ax@HiBVEh@_ARLh@iAOyAf@j@PeBv@Yp@Kc@sA\\AXu@a@eB^_@Rr@X{AJdB\\qAr@aB?VTmAh@\\DIrA\\@hBeBWbBoAbA`BAIz@|E_B@Vb@Gn@m@Hd@lFcCa@|@bFaAoCrB{ANJN_J~C\\TMd@f@FfMmAtB_@|AeA~B@lDo@_@WlBA`By@yB_@tD]W_@`AL|DsBc@c@~B?\\^Le@lBk@n@Jm@t@lECHXxE\\xDYjAq@UgAkCk@R_@YQkHj@}@`@L\\[LQa@vAy@aMe@f@w@dCoAuGoBo@{@qBk@rE}EjB?DcAt@?Y\\tAE`Bo@a@Qh@Im@QXCzIhAE}@yBC{@c@lD}@y@[jDBm@[pCMb@mAdB@|CiAbAf@wBj@@`@t@N~N_@}Bd@S`@~Bi@pCRjDa@lOEjBUb@g@SWjHj@|Du@xBkBoMH|NqAbAiBiAi@f@QGk@{AaAY}@gBy@eE_AeGYcHXpEpAdClBeAz@SnBoFlBpHfAuD_@_C@YZa@}B~BMpBy@uBo@oEd@nBu@k@MnCG|@{@s@m@_FTjFu@uAm@wFOVUsEe@uFB{AlAmBd@Ad@_Ab@~DvA{B[`An@q@Tl@D@^uC{@{@Dl@]w@WeDb@@l@sA^t@k@_@q@kF`@~Dy@qCq@yK\\cCz@nDlBkEkAiCVTZnDXcBDz@zAeBuAgCWSH`B~@aBe@iB|@Rs@kA[{F^yAn@~GxAqDk@uE?Pr@`FfAyBM\\hAyAyAsDq@eIn@QTVLzHhA_G_@l@j@u@FeBa@RMi@Ys@DcBp@a@t@dDAtFr@qKHoBt@xIG|BX{BMeCXXr@bHQaF`@u@b@uFL~@Vg@TP\\s@FEc@sAM~@tAuBk@O\\eAIUz@Bi@wA_@[Hb@f@kAI}Ct@l@LM\\hC?UDgEKmBZbCrAgGQs@[cDnAbD@mBt@bEG[PVPkBd@|BB","nlL{zNaLgAyZsAaE{@`B]tHF_@WlB[[OvGUlSp@z@?yKw@dUe@`HR_BV`Fi@pG^{Cl@|Iy@hGGrGFuHnAvALdHcA`NKr@DwGl@bMS`@FiA\\dGGwATxCP}QrBnRoAh@HsATz@@dMiAvIl@{Hn@zQWrUzAsJ@|Cf@}@D{OYlJZn@J{ANxCXgAPg\\s@rHp@jPXuHp@aKkAqNUfK^pGfAeHTiQq@bCd@uJMyFe@oBw@mM}@vGr@tAj@sJPhe@jBkK|BqDBfAJpFOtKkBjPETZe@t@eFd@qIxBdEC_@LsOBnHh@xI[hJZfCdAJn@_CHeEi@z@r@}BEaDsAXx@iCJhGp@s@`@?f@uAJwEEkF{AhElBnDLzEQhFoAzFNLJqAv@mDr@|ITzDn@LbAkELIeASfAsDBAg@aDh@m@g@}Et@sEIK_AaAl@kICz@q@wHp@[R`@^g@FwNoAw@w@x@YxDFv@s@~NS{CMjA_@yEj@_LKqA]MuAwKG{CcAtFc@sHEyAc@lJy@bILjC[iXDIc@~EAbGu@wGRkMe@t@_@lFOu@EeKb@qEMw@Qp@K_D_@jHIgJUxA}@gF^y\\}ErWl@","fqVyqLhDi@|GGpBtA`DDZMgAs@x@O\\kAtAVk@FtBfAe@f@dANpCg@bEuCvCu@b@\\aB^hFdB|@Is@a@rFp@hDdBPo@pCBxDdAB^i@Fb@LlCEgHiC}EWaG{A`FCzTnDy@s@hBa@Hf@fC@xCfAaAN|@RcCn@pFi@","vsYsmL|Ge@dGuA|LYhJ}AvGZhRo@vHw@tOHa@UjCQa@g@xDO`EX`E{@~Cr@b@QqBe@dEk@SM~GlB`JLnA^iCTrABJj@l@I[o@^CdKtA}@SVMtEpBh@hAjAj@vCl@|JH`@zApALsOtCwBdBiJNSFn@v@{A\\oEKYd@nGGxCaBp@J{CfBmCFQZtAQ|An@`JG`A[q@}@TIq@@bDClGz@c@^xCApI~AiCn@wFXxCXeBl@Jf@cA\\gFX}F[qB`@QSp@QSGaBt@}DwA}BUo@XFj@nB\\cBb@i@tA\\^rBd@xCKhE~AxADWU~@W|ADVP]RrATAz@g@Qp@XONtADfBhASl@|ALu@RfAHOZo@E@f@y@n@{@a@LL}APn@b@oHD~@HaBP|@`@t@c@hAb@Ic@`BWjBdA_AF_FhCkGc@Gi@\\MqCqA|AnAuBhC\\vAcAF\\PMl@rA\\{CEgEkAwAl@q@QoCdB_@GBiBmAWcATdACL\\}@n@qEu@aAq@^lAlB~@E`@{@JrAZPhAo@`@lAC|Al@bAr@Bp@n@QrGpBn@z@q@f@rAKPVh@[US~@E~ATzH|CJv@k@k@yAJGq@o@f@_AQmB_B_ANh@?H^w@AyGyAUh@uEmBURg@Wt@U[YcJqBm@e@F_@}GcCwC[F[iDyACi@vCg@iBaBoBKMOd@ImB_@fA{@_BD}DmCcE_A}BBeAi@{@AnC`AaEx@nG[nDv@N|AxA`BoAXoAWrDbBkEB_A}@qAAaBeAU\\g@cAaEV}AaBfCAkAe@z@]k@CYc@XEuB_@n@t@uI_@~@JF\\q@Pb@NsDNl@`@aCRoBy@h@`AmCd@GXoFO_GXs@]WVVReEd@eE{@wA`@l@BLh@Fu@b@M?p@x@\\aGjAaEtByDbAmBi@lBgAxAKM_@wCr@Ow@Yb@e@Eh@N[rAaD\\pA}D[k@m@rB}AzAyA\\aAy@\\xA}@l@iBGdAHsAr@hBMc@|AaCT_C~B}Bh@x@HhApAQb@a@Ba@aAuCi@RXkA~Bz@^g@dA}@JcAg@_@eATeAWo@BdDq@cBo@RlDnEeAn@yAIpARz@pA{C|AwBuBuACl@TOf@uCv@bEy@K[~@`@aClA]|@_AOj@lBaAe@Rl@~@^s@hAu@{AwCoAKe@KVn@r@_BnAhBcAf@FfA`@HfBc@d@aAm@aC@pCPx@zAaCv@kBe@?d@]DZ^yBClBTgAPyADSc@K\\sAAg@gAf@xAgAzAoB`@s@kAAtAy@Jn@P`@]Gh@sAXcAw@PbAoAKdAZETaClAIp@^JsAvA^l@IpAv@l@nA?g@g@KVe@MR_A]}@hB|A[Fj@H_BgBVs@nFEpCi@IpAw@|@o@lC{@At@Rk@f@Jl@Pc@D`AiDXxCMK~Bj@xJlAnD}AlGx@xEiBtBYlD}ClCPh@_B^Y}@kDNzCHo@hBjA_AHr@U`AsBfALfBsFpFCjBmE^}A|@}BPSv@aAByBrAqA~DwA`BFx@wBvCmAnEqCpAqDvDj@bBg@p@l@e@rCDIRuGjEwAm@[hA_ChA}@~B?pCuIvFeAlCeBu@O_AfDkDTn@x@o@DiBvBwDv@wDl@a@Sz@^S|AgDnA}@ZuBnI_IjAwJwJnC@hBsDdIoEpEuBTPh@[`AkBn@e@jA}A`@_@v@|@j@Iz@c@Z}@[Zp@o@KsC|Al@AaA|AwAVBXl@UcDtBeFxF[~BwAbBDhAt@|@q@l@pAd@e@pAkBdCmE~A}AbBoHhAuEvCgF~AyDd@iEnBwF~@uIoBj@MW[UXa@KF\\cCb@hAs@uQhMsDPiKnCb@SeCTi@m@_AH]nAp@QNZ_KhJ^d@q@r@d@dAm@lAkBz@e@k@dAsAiBbAY`AgDrAYdAVd@YX}@Lb@{@c@HqAtBg@q@}Db@m@rAm@Pi@u@i@fCgDw@xAmCmCmAKq@q@_@_BNyAz@St@A]c@TWScAhAjAs@v@`AqEfHRnAk@nAv@n@o@bDl@dBUGI|@OWC`@c@CpClFzCfAAtBh@On@l@a@^Fp@lFtAGzBtAhBg@p@b@EPr@dAl@[hDf@r@gCfBu@gAOf@Q_BUdBz@`CxCbBtAbBTvA{A`EHf@l@FCb@eFlCuFpJaFtOqAhAMnAyDdG^`ByAxCeDzCiUvJe@lAcEnCu@hRn@nGr@pAg@|@b@tEWxBr@hBKlBjBxK`AlBg@nCN`Bt@d@Fz@s@tKx@vAOlBbAfBj@xDnA`BvBzHjABJ\\g@jCJ|Ay@lCb@pBr@`@z@fFeA`CkA@y@u@qAb@k@e@bBpA_ALQpAj@e@ZJ[p@j@^QhA~@v@O|@t@jByBt@B\\nCdBFZeBh@bASzAX[rAr@dCRGi@oCl@bArALiA_@g@yBZI^RFt@tDR]x@|CxBw@^Tm@gBe@Al@cB\\g@S]lA~@p@eA^nACVXsAt@y@Gk@u@aA`BlF]UzA{A@`AXD~C]Ry@_B^pAq@b@xAADb@gAh@hACz@j@gC~@n@C@Vk@j@aAcA^xAdDsBZTQd@nABw@jAmDh@AdAt@JyAVm@n@wDo@pBeA}Br@ClBd@R[_@l@q@zAh@rAO\\\\|AUy@fBsAH}Ag@Vj@pAXiBl@qAiBoFLrF\\~@~AcBj@u@aAqE_AJj@xAf@Hp@RFHy@|AV}@~@kE\\w@qEkF}@kAq@iDRrB}BbBSqAEZoB^FsAaCgBk@nB_@_AK@e@qAdAeAWwAmEkIiErAUyAs@LqAxD]pCqBPmAcC}CuC{@mBH@eAkAkACmCyDwBlCi@KQ_B_@{AhAqAc@@sA`@a@tATa@HFX~@DpBaA^eEO{@g@MyFrAsFu@]k@\\}Au@iAHyAYAp@YDwAsFd@iQmB}BeAcDaFAqBfAQz@oAi@kB|EgCp@kA[U^iBYmBq@]DaBUbBt@h@NjByBvCcCEcEzAgBYyC\\qCs@oHqHgBaEJw@k@eB{Aw@]uAONWmAZaAw@bA{AWBd@ZAEt@x@lAb@DTfApAx@jAJCv@_FiDiF{J}D{Cq@sGf@yDYQHcAu@o@`Am@q@LOa@g@\\u@q@JY_F_DsDwAeB?Yg@wCaALi@yC]e@Rj@PgCUMs@e@n@wDCKiAu@u@mC}@IwCiFmIYgGcBmBHgB_AgGb@qFW{BT@FyAaAgCPW_@a@e@bAs@WmBsCaAiD?\\sAsBkBcAeB}Cy@]}BaF_@_FtAwI|@wAnEErBe@dGmFfHkDnHJzJkB`DtAO_AVC`BpCY}Bi@g@Jg@j@f@RSk@k@T{@XHz@aAp@p@XgAhJmC`@?CVjCE`ApAS`ATXp@WbBtAtArCaAyDxEx@x@s@f@cDrE`ClCGgCc@kC_GgB}@kD{DJcBbBa@p@{@dBgK`Ak@Rp@b@sB`@x@JiA^[RPEa@xBaB|DeA|@rAUyA|@SdHILr@tEy@h@zAHaCtCyBtAKr@zAe@{D`AqAnGoD`Fh@~@e@iA?{AwC~CiBb@NEb@bAeAn@f@l@sAd@ZIm@j@o@mEi@bNRqBZvGtAnCe@nAiAxJZr@eCtAu@~CIhAkCx@x@St@iAICf@z@IpGnBBtAwAbDd@lBjAT`ByCgBgDhAuDmBe@u@y@Vy@bAQtI`FdDCr@lBVQe@[dBUpA`AzA~Ba@MRvClANp@jA~BrAY~BnEcF~F}A|AKfH|C`BCbA}@Ub@z@Dx@cBbEqDfBcEk@cAb@k@o@iDOz@CiG{@wBj@aBq@_@h@q@v@Y]\\r@?bAc@YWg@T`BqAlCa@dBPdBWhAn@lBB|C]tAh@zAq@@d@x@YsBsCk@_Ir@Y_A_B@fA_@h@OQ{@yD^Ko@m@t@Kq@k@CiAoBcCGiB|@g@r@Lg@?JRnDa@xKbBv@pADvCf@j@HjAjCzA_@t@p@^jAMJe@|AFrKdBnCiB~BI|AuB`@iBpCiDnBeGiAzADz@Sy@xAmBReBe@aJiBgIz@{AHuCv@k@aAX]cAZa@gA]P]_AIMm@eAGj@q@}BNj@Z_E}AkAqAXaAe@IIf@q@?l@`@mD}@Iy@Ap@aCIwDj@iAw@kAv@s@?Sn@ZDkB^iAa@a@f@_@o@Tg@oCdAKh@gAk@jCsAiAg@Hm@l@\\d@UO_@jBRXMc@o@_Ch@gCq@yCHY_Ak@nAj@JqCUw@m@AZh@P{D][N`A@iCl@_@WN`@gAvA{Dm@M]{BXoEnEd@nESZYMLc@u@Rz@fAsA|Bw@QDpAi@[Xf@o@dBgA\\sApBf@IE`@}BQeAwBOcE|CeK]rAOc@]zBJuBpCcGt@iEKaAeAuCsAcATm@k@^UY^QiC_@A[aCaAIs@G^eA_BeDe@Ks@Cl@c@cAiAq@Dg@UXkDo@B]dBGRWy@^u@w@hBs@mDb@oA{@Ae@PWl@t@Aw@bC@?u@a@h@qAY?YaA`@XmBsAfD|AyE`B@b@u@rB_@aEv@~A{A}@n@e@QHi@~CwB{B`B]?Oi@pEeBw@aBf@~Am@GcCfA`AyAw@`@`@kCYKZ[]FgAaAe@HhA~@STd@`@_@J`@Z_ANn@D?^i@^e@OAlAe@B~@lBIp@qD_G\\_@Se@~AkCU{@mAg@dAd@Rz@{BbABh@aByAiAwBBf@UyBx@YaAkAFcASd@V|@uEkBwGQYsAo@HBr@}Ak@Gb@mC]^oAXAo@r@tAL|@}A|@SwAy@j@i@aC{DiA?_@k@ETmAU}AsBHl@w@NOq@mAXuEyAXcAgCLqA_ANRm@R{EsA]}@_ATnB`BkHAnCZHf@|@_AfIfDk@M~@tAK`AaCx@oFaD@k@e@JS]mA^Ag@y@FgMaCtAOc@MLYtAQb@i@|Cj@IQtBAxCu@m@QPKzBMhAaCOa@`BGqAu@i@eAz@YxBb@n@o@hDUyCa@eC`@iEiBr@y@y@EdAm@|Da@vEZzLvCrIjG|H~ApEjBnArBdFtAsCeAdAc@gDi@cB}A{CeAyDo@_G{Di@gAvFgAmGr@uGgEuD]uA_CwAk@sVQgEf@gI]yHqEqH_@_GgC|@q@s@OpBMgBS?qBbCoBlDz@d@[mAc@x@iAxCM`@NuAHnCVnIlCm@k@z@_@s@H_Am@mFk@uFkBnBy@bDFj@}@r@IrBlAoAiA~@YjDp@uAsAj@a@MY|Ek@]IJa@dAAw@YdBKg@UvCu@aFVGaA`Co@Ka@rBMiBWA{@hBo@xCZyAe@k@u@|DZeCcAb@i@l@@Eg@nCBmB[hBe@g@Y|@}@`@NH_A`BHeA_@NS~@KdArA[ZnAACn@iA\\vAOM^x@R_A\\bCn@GlAfAgAfAz@hDv@Tj@NqAz@r@a@_@Xe@lAvA|B\\gCs@Y_A\\s@~@_@xEh@fAMiAg@q@Pe@uAbAGScA\\o@vDUcEINqAeAgAL_@fB\\x@i@bFQtAw@i@c@rBDc@Qd@]jBAOw@jEeAvDdAlCe@xAXUMpI_AvCx@UxAeBt@dCrCsBAd@d@y@Rb@Tm@v@ZJ{@Lf@bA[RjASXf@SRdC~ANp@_EpAcC~A_ArBGfCl@bBjElC`KzBmCdCRbAgAfEy@nAhB`Bq@t@`@z@nAsAlAt@Bl@gAfAtCgBjAEhAb@qAs@j@iA`DwAfACm@i@hCwB]}Cv@sAg@qB`@s@nHo@dFB|@h@]u@zAq@fJsAlG}C~IqAxLz@iAoAtCyGzDAh@vAGsAdCeAa@g@[eFTOq@SwBoDwBy@t@a@q@@sAy@z@[gCd@n@i@k@a@yBGrAm@iE?cCy@Jo@j@QlDs@|BZ}@YrFs@UTx@EZw@aQpBcCU[U^c@}@Bb@c@uGXaCm@aE_EbK]|DuAlHa@oI@cJtBgC?iGyCpCo@B_@}EIaEfAwC?dA}AfBq@hABqBKaFvBcKkCc@uAxCuAIs@h@F`A{@}Fi@Re@rBE_Cs@tDUN_@vAOkA]vRg@[xBiB^p@l@o@LbDRr@vBxAt@^~@~Cz@|DyCFcAgBAh@eBvFyAlAp@l@fBdAj@~@g@QqAvCqAcDa@jEMo@UlCTbE[uDiAhBKe@[wCJzGeCEqA~C_BbFk@bElA{AN\\\\bBMlA`@RtAy@`@iAIRLULrAFp@z@uBpAiG\\gAd@sCCSR~@\\Ye@|BT@^c@DfB~@kBBi@k@i@hAe@@`GlBnCHd@v@mA~AZ|@~B@q@~@|BuAgEF?]fACK[rAHl@o@oAsCtCh@y@u@lBHfAk@`BITB]VjDD{AvA_CGqA^|@b@jFyALRy@^lWLdFkAjEJpDeA|@sA|AQlL|@pA|@eEXU_@iEE]_@mCMdClArG^GXf@P_DzDtBOGj@yAbAvFiCeBA?_@zBcAn@b@d@u@tBGvAs@dN|@lO[z@OcCiAeC?[YbCeAbDe@dJDpTcC","bzNqkIlCBd@ZeGz@pA{A","zjMchLhF?u@j@uE@`@m@","deNmkKtC@wB|@qAWr@g@","|cOkhKdCFfBbBg@n@gCIuDwA_@m@vCK","toN{dKlBFn@l@@x@wBb@oAmBf@e@","xmSybNbDaA~A@iDlBqCB@]t@S","`eQkdNtMY~C^y@\\mLDmCi@","~lRirNqB`@c@Yp@s@zENW\\_BA","prP}cNhDI`@^_Et@kBY~Aq@","jgQstMpAw@`Ei@tEd@`CpA{JlAoC?uAIIyA","nnTyjNnDI`NpAqS@","nnTohNcCQbCw@","d~UyyMWYvJ~A{CRcEyA","dnSikMpHMtBh@aIvBwCwACe@t@U","l|QieMr@JIYnBu@|CLgFuAfAU}Ba@`@]lKPfDk@l@Jo@PfED}A~@zA]jBT}Bh@gBOkB\\lC@qAx@t@MNh@|CHxDgAdBz@eE|AaF^qGvCoBP{A[r@aA[O[d@wBDaEg@^k@c@]h@WeAm@P]`A@","dwNknKSEdAa@`ENi@e@hAuAhNcDrAf@~@q@YULYhAa@fBlAz@~DWtAfCn@r@t@{GMkArBwCe@iGeDeDh@Qj@iGp@}CcApAS","~wM_jLrEP`BjAAbAk@n@}@DiHk@WmCvB]","rwNgjMF\\wBTo@fAwARiTW~E{BhD]~NWl@HUn@","rbRkxMs@UOy@h@gAbEe@|@NYZjF_@bD\\uD`@tALmDPpBDcAd@`LeBv@^sBF|@^wAPNLk@X_AAhFObCv@qUSrHdAsBR`AJq@b@kI?{BIV[oALh@y@m@Q}@PLq@xAM","rnSamN_@J^b@uCc@u@P|@VcDF`CTkBFPH|GBaBl@cJOWVsHJ_Bz@}BR}CMa@[zCaALUo@[dDo@hCBQ[bC[nDf@`AeA~CWnIDd@h@gCJ","l|PqyNbCm@`JK{Dh@tKXn@^{Hv@nK_@j@`@wBb@fDHcEb@eFIlAZfLQ}@x@wBb@cHMhG~@}BXgM_@oHN|PnAiEd@|ADgAZwGLbEN_Ht@wE@~@Ye@CaE^lBwAsFrAi@g@Xe@wCFt@Yi@aAk@GRx@{@LcCy@_K}@xBaAfAX`A]~DH_A}@zDi@y@QFc@hDIP^qA\\bCKbAQFq@vGO|E{A","lbPmpMyWSw@e@b@[sANa@p@_FPqHSYk@f@S}DCbEUmDm@\\e@`I}@rOKjL~@WRfPs@h@h@pCWiAm@hLsAoKDYUnLg@sD?rB_@vJFhB`@_A{@dN}@~EPgA`@bBDyFd@lAPyCl@sLQsDbBWd@bAfAaAb@PLi@r@qAXsA@q@a@PRsATeEJmEw@e@H^t@uDF","vzQqeNmH[]WlAWwAYfMoAfFAu@h@r@Nu@\\}Eb@fCR}Bn@mAG","nnTagNjMNbEf@Wv@qFd@}Ic@Io@nCMmDS","ndVs|Mb@MgE}@`@lAgDT]m@qEc@z@e@wAGfBg@kDa@nEo@hBNcAPZF`MDlNjDjDJ~Ar@}@EH\\o@LmFGoAg@wAfAeD_@f@[_@i@cBh@wA{@","nnTcwMfFErH}B~EAhAo@rBKrEh@qEX|GCxAl@mIJpLr@sKGfLj@bAd@uJz@_A[mAXqBUQO\\UcAF]e@mB@jA^[d@wO_@|SnBoGt@{QyA","nnTo{MqBXhAp@sDn@d@TtDE","nnTyrMiFi@oIXsE_@yBcC\\g@xCUxAT_@Xj@Z|@i@~CP{@g@dCMu@_@dBgA_@]fAOhFbA","hxPqmM`FFlFi@vHZ^^wAh@zDEEbDeAjAK|AqEDyBcBfCs@aL@iCwAVIoF}BrC[","ltQgmLlCcBhHgB~ATm@x@dBUIr@rEv@}@`@eAMkM|AiGiA~@A","nnTcgMbCEyAdAdHfAj@K}BeAfJy@jAZXh@zDJqAu@Ki@^m@tAMjOzA|EjATRu@`@pCt@@r@qFL`Bd@y@NoOSfRfB}CbA}VKiLjAnQ?`Oj@Gr@s@b@","jyUirLwC|@aNZ[tAiCx@eTg@uK_A{CwAgBv@_FNP^yLRoEg@M_@r@OMa@`@K`Ex@Mu@jAg@aDVCa@sAYwFX\\sA|A?hHgBD\\bBWxDuAWgB`BoAjBaDlAk@zLcAQXz@TkB~DmAr@bBr@jAQnBeDxFuA","j}VkpM~KTeBxAmAXfE~Bo@d@pARBp@jDxBoJz@kCdB}ALyEcAaFMwAkCeCQmBkAiUcDfLqClEArBj@Pc@fBPa@W~I{@bGL","boQwyM^D]^yACf@g@n@D","nqNy{I{@Qw@}@?~@k@iB|@c@t@rAOgAjBxBeBc@t@z@","`qNa_JU[dBr@oAW","piRilMeGs@~I`@yAP","|oM{fKn@A_CLnAK","nrM{jLt@k@`AXwAh@_@W","`~M{kK~Ac@l@PsBh@YW","|qK{eK`BNqALO]","phLuyJv@bAiB[p@g@","xvLufKhDg@iBz@sAARQ","|sKy~JWc@VM~B@_Cn@","dpNerLe@OhDQrBTaDb@LWcB?","bjNgrLt@^oD_@l@SjAR","xkNwmL{BmAhAK`DxAoB?","d`NypL`AAJ\\kAXaAi@~@K","r~OqtL{AA~COcAP","hnOq{Kq@n@sA@PPoBEbBSEWrA_@bAEQR","z|O}iLr@h@Mr@y@Rg@]By@v@Y","dsOs{KOv@}AT~@iAl@C","b`SsxMqB_@nCiAnIf@yBLd@Tk@FqCEt@^qCC","tqRq~MjGXkGY","teSa|MwASnEo@hBPo@p@qD?","~|RqdNwEIdHYmAb@","dqPg_NxBPoAl@uA@PWWUp@S","tyQqwMtBZ_@RsDc@|AK","|sQoqMbAJsBJn@W","zfU_fNtA_@jCTsCp@eCQv@U","teUm~Mk@MnAU|DRaFN","lgSiuMvBc@jBv@yCT{ASPU","dhIshHT]lAr@q@_CfC`Bn@Mg@w@l@A{@MFWuBgAxEb@oAu@r@IWS|@BuBeAHc@bAW`BTHg@hDxAWuATZFWlCFk@k@t@H_CgAbCFBa@~BbBQoAyDkEl@{@yAKKi@zEn@rAdAr@|@WPp@RbB`D[h@l@Id@\\m@^V\\UPpAQ~@~Ad@IMS`Ad@eDHjEzBo@~@{C_@yH`@uDs@@l@r@PmBDi@e@uB?bF~BY\\}AG{A_BmA_@B\\{@{AcArA~@nB_@FqA}@EfBkBQgAsD","|vQupLm@a@Eh@m@?W_@rAWd@n@","p|RemLz@\\sBDv@c@","vlSokLxA?yBT^U","}s@}lKcASfBNc@B","ilCywLeDy@`H^{BX","oaDszL]KdDAgCL","erCkxL|Bn@uAJy@[P_@","avAqiLpC^]NyCo@d@?","ooBkqLbBSt@VYRfABd@x@cFYI{@","{xBytL^Fh@m@zAzAxBb@oCBaDmAPO","k}AwjLdBF`Ah@wIi@qB_At@]fB|@a@a@Vy@{@g@ZO`Br@O`@\\p@|@b@","k}AinLpCz@cEFIUz@m@","qdAcuKlATaB?RU","zmNqzMfBh@aC@g@o@`AB","paMecNnANsE?bCO","~`HycO|DPwBp@oIv@y@a@E_@`@OfHi@","twBg`Ov@XyDZ`Cu@","voBeqN_Cm@rBWfFh@{EZ","lvBciNf@Nc@nAq@a@J}@`@?","rsBo|MOfAVBlAqAEs@aAHMn@","nsBevMj@LDp@kHDbB_@Ti@~BB","znBseN~@d@sBUr@O","`_IsjLdEf@jFOcA}@wAScEb@eC][[vANa@mA{CGzCGw@yAyA[\\[i@MfKI~IgAfBw@gAWqGLgLdA~Be@Ks@dB_@uC@lKc@TYm@]_Fi@lIP{@m@d@B~AcB_@Mv@F}AtAnBl@Kf@pCPrDc@N[eAsA_Bq@rCb@JWcAIz@SgCWc@{@xD[kA_A`Dm@s@g@lAiAfE?gBQ@i@{ASlMcD_A_@t@c@x\\cC~Kj@tIKqAh@^@hHWfF}@qGq@bJc@g@_@vD`@jBq@yQ}@}IRJUc@YlBa@xNj@pFq@yAWfFDtHkAeAy@cD[sOo@yBc@}NYkBu@cBoByBSrOBdAg@uOoC_DKcC`@b@e@q@YuHNcAu@ReAyCa@eFFsOfBpO{Bo\\iAoDd@UdBgBk@Ua@`@OOg@iLrAmGChF_Av@k@}DAe`@hCg@MTq@oAu@PStFw@gYH~^q@qS}@yJ^{Dm@wL`Am@_@bDi@kLa@}c@Is]`Ate@`@FTej@c@aCv@mQv@~Hv@hi@t@eATmZKkDz@}CMoBs@_IGi@ZBl@dAr@xHdBH\\yTkDqLn@kGkAwJEqP`ArIlAfGRk@Vt@TbMd@cDZlBb@jQAnCp@On@oCFJjAsA`@jLfBa@RxChDmDg@qGd@e@XrGEyAp@eJT]XHh@t@^vJ_@~El@nCSv@TqCTmAz@eI\\RVyAn@Ut@X|@|DYhFx@rBSiB^mCUe@r@n@^c@@wAcAwA?iCtACh@rJl@bDYe@o@j@QAb@fARYNZ\\i@L_JZt@lAtId@nDYxDgAfB^fDq@sC|@~Fx@pFe@qCd@jETmTu@aL`BDfBh@J@j@bJkAnBkBtK~@qJc@UlAbBV}BEiOfCjBn@CVKXWc@mBIm@|DjDLJoAp@rA~BA~Bg@tBqBhGeAnFKyF\\a@h@pE~@nIGsA\\`@b@vDj@_OF~EhAq@FeAi@kH]wSn@fD~@bDHY\\nB??\\pDb@SPtG`BfV~AfBIUXhAJlGkA]b@d@Hi@j@|D`AdExDhBz@nBb@fCe@o@z@tC|@\\c@Zj@lAOhCv@p@WoCuBnDQqAXlAfAjAU_Az@hK\\uBj@dCx@fD@W`@}@I_BvAvBr@~CO_E`@MnAbBj@bAxAzC]kBn@v@l@lEB}C\\`@rAi@x@~AZu@FnAtD~@p@nDOwCt@Gt@f@X~BS{@`@nCGLUm@s@lAp@xCc@Aa@}By@~Bj@dCc@P_@u@wAfEnAdGHqAm@zB?Fe@`BQXo@n@MSSd@g@mBe@|CNMm@xCo@Cq@eB_AvBj@vEcDNiAaGg@l@?ISvF\\w@{@uDm@_Bn@Tq@nAMvAuAo@nAbB`@O_@VDbCjBbAyDv@c@aHyAbH~@`CYQmAv@MqLeDnMrCPu@i@o@mEm@pEGvAk@Ow@aFaA{Kj@nB_@g@WpGEvGx@uBcCuH^mAiA","~cb@dnO{r@d@iYx@}cAfAdEw@_ECXIft@uAREgHMxD_@gCYzDi@xPCpGcAt^cCcNJgTjAoPAiFp@kG[uDd@gPBaN}@`CS{L]{KmAmA[tIeBwCo@dRkA}Ee@wo@m@SK~NaBgAg@{JOT[eAMpQoAnEk@HWzMM|H}@fASsLkAfUe@`Cy@u@w@CcAgTEmGx@{I@oKhAwHcAfCc@u@a@aQjAXk@y@MxAYcAWVg@|H}@lMMiGu@_Q|@zD}@mAi@}FKeFu@wFMcHr@dAg@}KmAsN?sA_AmD]wH^}IOqJV{Re@ga@HsJSkEs@oTRwBuB}Ed@n@v@sANpBd@h@bAaNe@WGh@c@B{@}BOmDb@O`@z@z@xDx@SPs\\VwIk@cJCyGDeCx@iMSe@Y|J_A{Ai@l@c@hE@|A}ApE[Dm@_LTkJ]rHs@lNKzA}AqDOi@PZ^qCF}U?cIt@gU[}J^aBOYs@oH{@uBThA`A[TkEGyB`@eEa@cD|@oPfAmDW]UBkAgD^t@aAa@[wD\\qCrAcKIQTx@t@_Jg@aGJuEq@uXcAeFw@gDsC|BoBl@gDlCeCLkAn@i@kGCoAq@rA{@s@QcAiBfAk@}A{AjDEIsA{A_@Y|@oAD?wBqCM?aA]]yACRYwCEZi@eDk@p@q@}@q@kCTJg@uBUEa@s@PyB]yCiBuTgD}@D{@|@bAL^]bEt@|C|Aq@JTJW`@dCa@d@l@|@]zElB~B@Wf@pBZmAn@lBt@{@t@_HYvAXUNX\\nAg@`Ax@fAk@nBDI|A|ByA`BBSRtAt@aAj@zDKd@h@QRn@Pi@RzBBNTSj@b@j@YX`@VaD?zBh@yAL^h@wE\\Pe@q@OeEKfDd@uAb@Vl@mDvAc@dAyAx@H^hANoDX`Bn@E`@aCEeAhAfErAuCSi@RF^rFf@sHH@fAlBF@XwCL]bAiAMTbA~DHxDc@iAdAiDh@pAp@`BG\\T_DP~C`@cFBdCf@lDMkAbAv@\\nEq@Bl@nCPiC\\pEZkETRHxQnBhQbAtD~@|HNtTa@rKVoEpBkDf@{NVnAz@|Cj@nGHn_@qAbERgVbBuANBXrAd@bNLnMqAhFuAmBrAhBJcB|@_I`AyCnAgA@Ge@cAIqWVe@n@hBt@jRRgH`@aJOiGxCyKJsMy@wD`AkF@{Q`BcSVc@JxN@vDNgHZlKNy@FLd@g@R}CPmZa@xL`AcDxAkC@hAdA_@J}JJgVcEiJw@eb@}@cLJi@h@|Ax@qCFgLk@_MaEsOsAwHb@wJqAcXg@cJu@gd@k@_@Szg@s@r@UTaAtOLxN{@nC_@D]wCsB}GyBsKu@qR}Cc\\wBid@cAxAe@eGkDaJD{E}AhICw@_@rBWOi@gDaA_GB_AWz@u@yEJuIkAkBeBH[bF]f@k@sAS{Er@_DsAz@UKOoEl@sBbB{DWWaAv@w@WSiJYeA\\`Bx@Fb@uXR{D_@iBvAwEwAqGu@ia@eBw@YgDT{By@_OtBuFoBz@[wACuCx@mKCqBa@aAp@yGZ}@q@sBOe@N`@jAkAp@kIO}BsBeCj@Pj@kCToEoAsC~A{IX{ToCkO{@iAkArAmBMg@eDg@oCDjAnA}CAwCtBeNH{EvAD_@iDsB]{AgAa@uLyAmBq@oT_BNk@o@Y}CPl@h@iA^oCE_@x@w@g@Bu@uBy@dBAn@c@{CScGp@DiAv@k@Q_@uHsAeJ_@cIP{JbCnEBaAl@fCbAeAJgHq@}Ej@eBp@uFQyLr@iE[}[jAuFYGv@kArAxAv@S`BnCZw@v@Tj@rDEtDtAkK^jAxBdDdAxBxBjB|DlAb@cD`@aCi@?cAyAm@aF]yIuCaBcDeEoAK{@cBq@{BUsIT}AgAuIsAeCwAMo@_Bi@sYsBmFaBsFHeMiAwIDyM}@sKb@uCe@kCXwHY{BTkB[wBf@eBCoHy@aBmAaGSmi@nEgFmADi@_Am@uLw@{N`EhAr@~Fb@N^WLwOmAoKVkIe@`HbAi@Hg]wCiDNqEaA}CZi@r@eCx@_HHs@KcBqBkAc@wSi@sCf@Uf@sEeAoF|@}CQyDh@cVp@oCf@oEYc@h@wAMu@V~BnCoKo@iDbAD|@y@RqGRoNUeAzAu@YoH`@}BYg@qAaIjCgKx@yAYqJbAeAh@On@eDdAkAdByCh@SMt@iBaC[gC~@qEUyRz@wBhAmKxAw@i@sAxA~BNd@`@Nv@}@EHf@bJe@wGdAv@~@fB\\`CYrFNlCi@kAv@oD`@pJf@q@\\vCZj@cB~@BJ^m@hAf@R}BhBlI?bAc@~CvAd@rAjHP_GZI\\sBl@jAz@y@NQl@\\TQb@|@h@_CLgBfA_@|@oCp@dBt@aCR}Cw@o@t@wGh@@`@bNQlKz@`EgA`@ZyAdAvEBlA\\a@l@dC^iAR~ARiCj@lKpA}KDVX}@\\pBJ{AV?x@p@ZaShDtNj@UNyIF{Ic@yMxA_Bp@iEB|Df@yAPiQPoDd@oq@lC","_db@c~LpG`B_@l@qFa@","}tIq~FfDn@p@a@|A?h@k@h@~B}@|BHk@cBNUfAfAKNdAkBHa@r@ShLx@Eo@O\\?pK~@dDg@~CwB~De@n@sANcDeBeBe@gDcDQd@k@nC{@xIwJt@gAe@gDd@~@\\kBtBeAGi@_A_@uAeCHe@a@RkDeAo@DHe@qBg@Sm@mJuB_Ev@cA_@wAFq@v@D~AhAzBuAn@bJEpAvAiBlA`FQ^h@sBj@}AlC?|@_A@w@x@kCTXlDkAhCFeB_@u@{C]cAxBsBnAGl@","_db@cnLdFqAhQeBnNI^XlPy@Ap@bAl@sAHsAxAzAz@xDFz@y@hEe@\\}@x@]nEl@rUi@lER|Bl@E|AlChA_BiAz@e@XeBvEa@RQw@wARg@pFuAnP_@dTt@tC]aAGdBu@xBQlD\\qBm@|Iq@{Ee@fBs@~La@lFfBnDTf@IMc@kCA?y@}APj@d@S@oCgAvMXz@Q_Lc@~`@yA^HuAp@fHPzA`@G`@iEEdDn@gALBb@w@h@|IU|@j@mAH|ARSRpJaBtGl@hEGnEcBxElEjB\\r@i@j@LzCm@vDkB{BOp@c@z@^jFaCuH~@Ym@XWjDSeDa@`Ce@cBi@nGiAlCM~Aj@r@KF_@`CRrE{@~ETRh@{@n@zAn@|BMk@Th@DlPSfGu@Ei@_BC`BUlRU~FVlBNkA\\`AVbArAwAb@hBg@cAk@C{@`AUs@e@lAs@d@ISVZP`CFvDs@_AK`G@tBj@oFLnRnB|FDjGfAqEiBaBCuBy@yDMaI_BE]gRkCaDuAxCW`Bu@kEv@kA{@|@Ex@gAxBf@[Ob@q@fC?]SbEo@hQCzAj@dG@kEsAdSa@sJy@vJcAxHj@rIxBe@f@`@ZeCVlPGkEbACh@|@Xm@m@|Ei@nLdASS|DRuAe@bBKnMFnAVcGBzWhAtNzAbCGLRiBJxBx@bCSpA`@_Hx@dDQnAXqBNgEtAjIrAyCv@pDaAwFkAZMlf@~@^bAsAv@b@n@a@j@{Fj@oBbAoDp@jArBuBlBdCt@Ad@nAYeAgAB_ArA@hA`BJg@c@i@ByAcCe@q@k@VI`H?rDiAjGy@zDAtEv@yBBa@b@~BLfCe@fDVoAfAcJv@w@n@iBNpS_AxA_@k@k@p@_@C]}AcAZw@l@k@rBAu@r@LpAfGx@rBpAyFjDvCzC}@jATj@mETmF]iIbAeA~BbALLvAiG^dFBvBi@MiCzCeBjGVjBTd@`AeAdAEz@jCfAv@pApGvARl@p@N`L]|Cc@b@i@oIF|@\\cANcD_@`@w@_BIsFwCMqAyA{@hEaBTcGm@e@?cBdDcBkCmBo@gCdGk@dLI~E~FdIvBi@B\\n@sBNXfBQT|@RXt@Sd@_@[oBTmARiBdBwBB~BvBjHgBbLkAlCw@nS_ArCr@wCvBvAr@tBBCn@d@V|BQAc@s@W|@u@`KzAtICbBf@J`@~HMeCa@`@e@i@i@`@MiCOhCAbHlAkAX~@XpAe@tPdCpAEOj@~DRh@vBhFb@vBQ`A{@bCk@}BeAgFQ~C}B~Ne@mDrAEp@xAtCaC|@Kt@lAtA?l@nBUUYrAa@jESpCnAjIfBBp@_CpBjA@z@d@~PuBdAl@I\\wBjAyCPUl@zBv@~M}B`@WOg@\\m@lAqAiAiAPc@rBw@tCS{@U~IoCmELNL{@b@iGx@aY~AaHm@yEiBa@kAl@K^wA|^gHrLWjBn@}@gAtEIx@]eDDQWlEm@CZxAT~ECd@[\\ZnACPo@bDa@gHDkCk@pDgAvF_@~BxBOsAhAA_Be@n@a@`DFPH}@\\~DlADs@a@c@j@KdH~Ca@kAoBgA|E]nA`@_@XnExADt@dCmAfGZwA^a@n@jBc@`DRPj@i@`@hCd@y@y@BaAfAX~@nAOgAfBDtBr@}@\\bCY^|@|H|BoERlGTe@l@Nh@z@eAnCd@aAP`BC`A^BZgBSH`@wANR`@|Bk@fBz@cELdGNzAh@}@VtC\\PpAuDMxFf@q@ZbAJnBzBy@RcB_@pAn@dAOdCf@d@l@aAKrElAfElCaBf@mDs@]k@kAAv@Re@R~B`@E`@rCL~@m@jB^BYnBAj@Rw@^~Ab@{A|@fBo@hBA|B`@h@h@qGAnBJ]NVFrFKh@f@mBK`EPrBj@FXa@PcHLfJA?`@qAj@bAJUn@}FJk@_@qBVu@s@PXq@PnBV?Zr@e@`JRUnAiBIdBn@]n@oAP~ADJxAgEmCiD_@|AfAGi@j@@zB`BaBFbEr@LnAyFmA|Ap@Jf@i@\\s@Ir@X`B[BlAuEfARZa@H_HOiHcDR[w@\\cAIsAuCm@rBoBTn@NSzBc@DCj@q@\\Ep@iC`D{Ax@B`@f@@[b@`AEcBjBNbA_G@a@YV{@c@]eAIEYqEFgBuB{@}CP}@i@oAVk@u@KlC]yCAeG{AaAk@bCEiEoAVu@pDoBlCUVoDM[u@FRe@s@gAj@w@}BQ^u@sBE\\S{@c@mLiCwCuBjAeAuA_A^e@g@JF]gCe@]s@kCVqHWGd@gCd@PbAUXN\\pCJ~DvCtElAx@h@OXxCTS^b@@hApAo@bANx@eAnAp@zBOhAcFj@VdAyAc@y@d@b@f@cPyAw@MNYsAHe@g@Fh@kKu@[L^L]^uAd@aCAwAz@hESbD|@t@Q`@hAzMm@LVrEHpDt@Jv@q@X`@Xs@z@wC@l@pBOhBpCv@fAWrC}BbD\\jCxD_@rIvBGwAiAOu@tAfBlCb@Tx@xAn@pBBnAiA_AJdC_@dVvDlBi@Sp@}BJCb@|B_@l@eAt@@rAu@xAKjFhBlBM_@iAbFQ]g@pBiAk@m@Nk@uAu@VOw@E[cAuAEc@g@`Cc@CkAk@o@N_Aa@c@`C^hBpA`DJdAfAcBBcA}@[DH|@|@Gj@n@dBUDhEaBb@K|Bw@p@~@p@u@DM`A_DjApB}@tBCV`B`@k@Xb@d@s@jAArAH^t@]RjA_@`G`@vC`Ax@|BxAjANVm@f@b@@u@XbDM{C\\~C?jIxAd@f@A`Bb@^rFlAJd@y@JrB\\bEQVy@vB?w@~AAlAe@^dGTjB{@hHz@?b@_BJbA@q@VdAXiAj@oHd@gA^L^wCPpA?KdA{@fA_Cf@WpB`@DqAn@o@|AhBiB`@jDa@C`@Xl@fEdBh@`Ia@vDRhGm@pFR~Bi@fHtBJd@}@b@Rh@k@I@dAYBf@^U`@Tb@YJO|D^|BhBzCTjBcAJeAcAzArAGh@kBDh@EMjEd@nAiFBaEu@{AxAc@O^t@cAxAqA^{AmA_D{@cMK}AyBwDo@P[y@mBoCwAnA{@X{AyDyEy@Ub@S_Ao@qEm@iFaCRk@_@Yt@mBk@eA}CoAcCj@eAMsEhAyAYiCcBsCc@yCkB{G|A_AbEcCtAD\\cBRaHpE_C?}@tAsANVXuAMc@tAsCp@gBpE@b@~@\\l@rBqAPaB}AMmAmBg@JkAtBiAqAmC{Db@i@bAs@VWoAdBwAxKiDs@mAdEE|B}@hByAxAoDfFaC\\cBu@q@x@u@Gi@{D_AsA?]Zr@\\cA~ByAcBo@F{@j@Y~AaB`AdA@MP_D`CuDXcDjBdC_@_IpByD`Cn@`FWp@Z?iBhAyAlC_B~@cAC@XbADcA`BeGKwC|@`A^lEgArAXv@|@oBbBR|@_@j@]Ve@y@qArBm@cAwAbAtAyEuAh@aAYvAwAaBYuAdAQQPeBvGuBcBa@\\u@o@H@^c@MrCmDG{Ay@QBd@qC~Af@cAgBx@j@gAyBb@|Au@TaA_ADoAm@}@P}@YwC\\q@l@gCCjB~@Hl@cGqDcHGSk@jD_Aj@cBbBsAqAw@GwAg@i@uASQ_Do@u@GoAa@AHj@eBOa@cBRkAuB]sBgCwCm@DVcA@ToBw@pBkBBj@\\`DO{AVb@\\aCf@}ECM\\dFlBqEz@TlB{A^oBaA{BOkA{@wDDe@_A~ELvAkA`@aBkAq@I^t@`@cDeB_EQuCgAkIg@Ml@dDl@K\\pCBoClBnBP~@vAGb@vATbA_@\\Jk@N|@ZaLjDeFbDuHpCcAxCJr@x@x@bFzAfD]pEd@~HwAhAPfAqAtCIx@aAdIFtEx@rEzBbLUFv@sCb@rEl@k@ZpEBP[Zb@dEMnBpALfB}COj@r@Y`Ag@Lp@b@]l@m@D|ATl@{@\\fA{Dz@@p@\\L}AbBn@DF\\cEIjCdAkBMERe@_@eCPq@nA}A^wCUe@wBwBHuHzCsD[iE_CiCn@cCaAYRhAbB]|@f@tA[\\OlD`Az@hFrO`DzAtEJdAk@\\f@`Ag@We@q@Zp@g@hANvA[NZvAFgA]fBTb@f@dFtAjM_CnD]bCPdAyA|Hm@b@WBy@pG_AzEfAbBfAd@nAi@zBXnArAlA`CV|E_CfLcBhB_EzJgBfEHvEmAn@kAn@V@c@nAGp@_B}B}AuAoBVmApAs@RgA_C{Bb@e@jB`Ah@_B~@GHTAa@\\ElDbAtDZ~Ag@bBZvAa@lFvAjBq@bE?lFz@hGN|DfAfA`AlD^dErBxDGXw@rAf@vF?zBoADy@~BTtAhElB`D`InCrCfCR~AzAdCLjCm@t@BbAhBpClGtEvGnA|BhFpDrBtA~GfEnDN`@e@WpBnFpBhC`@nEk@m@eCjDl@vCYGf@\\{@fAY~A?`EjA|DTlDrB`DlA^iAVkA~Ba@G\\ZaAlBiEWbEf@bA_@B~CaABbAZcAt@g@GPd@qATB\\kDc@rAd@Q^cABfARGv@a@d@a@UGbAWa@i@v@QO_@fBsC~@?lAq@Va@tAi@T\\FEr@y@IjAf@sAxBiAL@fAwCz@aHhEeFlEmDzAoCn@eIcCiDW|@CQKwFCpBAaBMyCb@]k@c@j@l@ByFdAcMgEiCIgAcAuKq@kAg@t@`@cENgClCeADr@NeAJb@XWbBuBlBe@[@Vy@EE[IZm@EPeA]`Am@WLi@g@`@q@UUXaCEFgA_A`@?j@Q[s@VOzAuA`@I]i@@Ih@b@Hi@b@RNw@zAZdFrA|Ci@PC|Az@Dm@p@yATf@\\xA{@HhCdAbAp@SgAlCy@BHf@g@Fb@Lv@_Am@fBi@Jb@@_A|AwAb@bAO{KbLoAtCK`Bi@d@{B[|Cr@kBdDoBzGB~@fAfAiDnK^tEt@nAvAr@tAbChB|Kt@~@EpLgCxDeElKiEpHG|IcAdDAjCwArHuAjCkEnEyB|GkDbGWvC~A`AsBpDRdAYv@Go@q@EOv@u@Ag@t@oC`@eBaA{FQwCgAeFCsDb@oAg@qBLo@{@{BByDoAiOwKqGcK}DwCo@sAgAqGKuCRd@r@u@g@gAoBkAgIyByA_BSuJNc@Zv@HuCx@gDt@cAJyC{@TsDcDmBk@kB{CqBsAyHwBwCiBH]yCcDw@uCJ{@XVNwHX?[cAb@eGi@eCj@}@bCeAPaDbAuC[uA\\aBs@cBfCiCLs@uAwGs@e@}CqJiBo@e@wAmAOuBsD{ImJqMcIaKsKuEmI_DeJcEeHSuD{AOf@Y^Tk@uGzA]fCxApEr@jIRdDrAtCQnDnAbCE~E{EzBL_DiBAcAt@yAv@GnAeA\\kAtBmA~A_CbEmAbA{ALdAv@qAzD{NzEuCv@cGHkDWY`A_BJuAhFyDf@eDG[o@CtBkBtFqLpAyFpDmEz@kCU{@SGgCvGaE`DgBmHm@y@bAjHmBDiKbPE|AgAhBkARkB~AwB~ELlCy@tDyArBmE|BsD|I}BrBcBbEA~EZRw@x@]xDe@~@HrAs@pBwAN}Eg@{BgBaFYoFsBgC?gCuBiKuBsDuACeBcA{@iHkBeEEsA_DsD]yAwCcEu@RcC]oB}@eAG`@s@@mG{J?g@lA]bCkDtHoArBmBt@mBGiGbMrLjHBhBb@v@Gl@aAz@?Ue@\\OcAwALiDv@q@r@`@p@rBIxBnCiFYeAf@iA[BlGsEtCgI`AEq@m@a@H^wAiDEa@}Ak@Fj@RmBbAgBa@sBzCEt@g@PoAxD}@t@qDn@i@z@kEtByDj@gH}ByB@}@`Ay@hE}AZyFl@mGTUYsDbA_LUM[cBIwBj@WYqI[bAG{@Qu@dA?hA}AR[tBq@x@oBXIUKr@mBq@fAv@oAfBgDx@eDaA|@fBrDv@l@]N\\oH`HiBRgEsAm@iAh@y@m@u@Pc@}BFr@LDb@c@@^t@qBIbBx@WATRu@jCj@fDW|Ag@Nb@C@j@ER[a@GXXjA{ArLcAtC]HWtBuArBqB`KyCnF}B~GUjAj@mAO~Am@lBgBrByAn@kB{@YeBm@k@oB_@uANlA[No@iAiCiBKAoD^UaCyKv@q@SS]j@r@{Ho@_CgAe@aAZ}@sB}A@cBm@SkB{ImFeCgDeCcBr@FMa@q@YOf@_Ci@E]YFiA{@m@mAVkBo@s@iDaAi@y@j@q@s@l@NbBa@Wy@RK{AUjBU_@e@PRy@S]Gj@s@\\YoBQdBsAkCLdAy@GZt@_@JmAiBb@mA]gB~@e@y@FGa@KnBy@tAiBw@kAhBg@jFq@jAqA`BCs@QjAo@g@L|@Yh@Wi@{A^RXmAbAb@f@`AYwA|AUmAmBvIhAtHk@Ms@sAFxBm@w@IfAo@Aa@u@GjAiAyAoBu@XaAo@t@cAi@[_AJiAeAz@c@rBeAI\\lBm@fFi@h@T?g@zDQu@kAbDKzGo@BrAnES`C[_@zAzGMjBmAEcCpDo@@S~AsAvAk@zAkApKsBnC?fA{KjGWj@eBW@a@g@r@UW~@{ChBeCA{Ip@uBrIaI~A@vBaAr@qASa@y@jBt@oEx@_A\\kCtBBNqEcDqJEkFsBk@gAZJzCcDGcDrBIQkBrCClBkAo@e@r@XbAw@UmAVoAhAiARK`@n@x@IjDX\\cAEmA{AeC}@bAwBmBvAe@OtAsB{AfAe@s@`Ac@{@?\\_@cAJKs@q@r@yI{Du@yBFkCWSS\\Z{F~AoH|C}Df@@bH}GUDCm@xBmBt@}AgAaEkBeAm@yALs@_BHaCmBiCYBu@U?c@t@aAFB\\{ASB]qAh@t@nBw@zBoA@a@m@fAsAs@oA_@XyA{@sBIcAy@gATKo@m@f@gAY[{@KXoA[?iAj@_Ae@e@cBtBVTiAR?o@w@@Sm@g@ZeAg@{@RaEm@wAkCkAKOVSq@uBqAj@k@q@_@qB@Bk@u@QMw@}@CTk@wAJ?yBx@Hd@a@_ALsAw@x@yAm@MKn@i@KgBsCk@_BNWi@Ee@y@{AFXeAc@i@VO[Cl@c@u@Yg@VIeAN[jA\\qBmAbDuA|B`@Za@z@J}BUwBwA{AYj@oAzDcCfCF_Ba@gFnAe@@@WfByB`Bm@CgAvByFzEsBs@yAiDgCd@[QO]EKb@q@OMw@e@EPe@_FaAqAXaAqBlEKpDiA`B\\bBfB|ABbAe@[iA\\cAxD[dAo@Tu@m@wAoFIqAqByE_BiCeCoBHSWuA|AzAlBv@T?f@p@RA^kBAZv@pBd@Id@aB[EYgFwBaDw@{AFo@a@y@lA[[sBl@In@n@~AkAV~AXjAtAgBHj@\\iAh@qAu@eAp@q@[s@RWtBm@p@TVjAe@`An@aANIfBi@z@r@j@WN~@rAY`Aa@?`A\\u@fAoCyANh@]P_@a@Ne@_AR@m@aAMoA\\Mm@kCOeAyC^SCiExEcIzDiBc@qB{DeAoFqCIiD_B}AaB]Se@V[yAD}B}Bk@CJb@gAi@@tAkDb@iDq@eHqDmAiBkJaHaCoCq@gBoHoGu@kBO}D_@a@RoCqAcDoBoBfAs@YoArAq@cALe@u@xHaEzEKFrBh@f@f@B_A{@F[rBfAjCHsBoA`B]aAc@hBRW\\Rh@vABOcD|DFtB]?i@eNoFaEeDeJgDcAaBeJ{DqQa@cBn@aBy@aEd@uC[}A\\m@Uj@?K_@oAFX[qB[mEh@d@LmDMuDbAbFZi@f@eE]cBRkBw@aCXeFUAa@f@QbDO[{@mPqJeLq@gAh@wCa@fBxDuBMh@lAwBUcIuD}AIm@Za@Yn@e@}@cCyE]cEh@d@VxBSlAb@pAdDi@VzA|@_@TfIxAtVjNxBAj@j@zBB]|@Xb@lDtBrAjHmBpMq@fA_@`Fm@pAmGgDmAqBWaBZ_@w@VgDaAwATd@aCm@qAkCeAiDBaBm@lAuCgAuByAa@F[c@QiAEpAx@i@^cB_@N{AzAWFuA}AmBrB_@r@l@tAgAc@iAaFiDk@uBy@]aAZaAa@eBl@Ys@}FkAh@bBYV_EmBuFc@yCA_F|Bs@{AcJ}BgBq@H_@u@JaB}@qDSqOsCf@SKW_A?Hb@mJv@yAyA|@o@]MNUjBeAH\\t@c@s@SFa@zAgBd@ZdAOr@[FaAxFb@i@Wx@e@lHj@mHw@gFRnDe@kBEmDfAyCVgH}A"]} diff --git a/demo/demo.js b/demo/demo.js new file mode 100644 index 0000000..ac5ed83 --- /dev/null +++ b/demo/demo.js @@ -0,0 +1,219 @@ +// The page is a normal customer of the library: one limn() call draws every +// projection in the menu, and the only thing that changes when the menu does +// is the arithmetic handed to it. scripts/build-demo.mjs assembles ./lib and +// the coastline next to this file; nothing here is special-cased per map. +import { rings, limn } from './lib/index.js' +import { projections, frame } from './projections.js' + +const canvas = document.getElementById('map') +const ctx = canvas.getContext('2d') +const menu = document.getElementById('projection') +const noteEl = document.getElementById('note') +const mathEl = document.getElementById('math') +const whereEl = document.getElementById('where') + +let coast +try { + const response = await fetch('./coastline-50m.json') + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + coast = rings(await response.json()) +} catch { + canvas.replaceWith( + Object.assign(document.createElement('p'), { + className: 'foot', + textContent: "Couldn't load the coastline. Reload to try again.", + }) + ) + throw new Error('coastline-50m.json failed to load') +} + +// The boat starts near Fiji: the first paint already straddles the +// antimeridian, which is the seam most maps cannot survive. +const boat = { lon: 178.4, lat: -17.8 } +let spec = projections.find((p) => p.id === 'mercator') + +for (const p of projections) { + menu.append(Object.assign(document.createElement('option'), { value: p.id, textContent: p.name })) +} +menu.value = spec.id + +const wrapLon = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon) + +// Boat drag works in the local frame at the centre, where every north-up +// projection is honest: finite differences of the real x/y say how many +// pixels a degree is worth right here, so there is no inverse projection +// anywhere on this page. +function nudge(dxPx, dyPx, polarity) { + const h = 0.25 + const ex = (px(boat.lon + h, boat.lat) - px(boat.lon - h, boat.lat)) / (2 * h) + const ey = (py(boat.lat + h, boat.lon) - py(boat.lat - h, boat.lon)) / (2 * h) + if (Math.abs(ex) > 1e-9) boat.lon = wrapLon(boat.lon + dxPx / ex) + if (Math.abs(ey) > 1e-9) boat.lat += (polarity * dyPx) / ey + if (spec.fitLat) { + boat.lat = Math.max(-spec.fitLat, Math.min(spec.fitLat, boat.lat)) + } + // Over the top: latitude past a pole comes down the far meridian, and the + // rest of the gesture keeps its direction of travel -- that is the flip + // the caller's polarity carries. + if (boat.lat > 90 || boat.lat < -90) { + boat.lat = (boat.lat > 90 ? 180 : -180) - boat.lat + boat.lon = wrapLon(boat.lon + 180) + return -polarity + } + return polarity +} + +// Pixel transform: frame() gives map units with north up; fit is sampled +// once per projection (and resize) because every bound here is independent +// of where the boat is -- the discs are discs and the cylinders are centred +// before projecting -- and a fit that breathed during a drag would look +// broken even while being right. +let fit = null +let px, py, seam + +function refit() { + const cssWidth = canvas.parentElement.clientWidth + const f = frame(spec, boat.lon, boat.lat) + let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity + const latMax = spec.fitLat ?? 90 + for (let lon = -180; lon <= 180; lon += 4) { + for (let lat = -latMax; lat <= latMax; lat += 3) { + if (f.visible && !f.visible(lon, lat)) continue + const X = f.x(lon, lat) + const Y = f.y(lat, lon) + if (X < minx) minx = X + if (X > maxx) maxx = X + if (Y < miny) miny = Y + if (Y > maxy) maxy = Y + } + } + const bw = maxx - minx + const bh = maxy - miny + const cssHeight = Math.min(cssWidth * (bh / bw), window.innerHeight * 0.72) + const s = Math.min((cssWidth * 0.96) / bw, (cssHeight * 0.96) / bh) + fit = { cssWidth, cssHeight, s, cx: (minx + maxx) / 2, cy: (miny + maxy) / 2 } + const dpr = window.devicePixelRatio || 1 + canvas.width = Math.round(cssWidth * dpr) + canvas.height = Math.round(cssHeight * dpr) + canvas.style.height = `${cssHeight}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) +} + +function reframe() { + const f = frame(spec, boat.lon, boat.lat) + px = (lon, lat) => fit.cssWidth / 2 + fit.s * (f.x(lon, lat) - fit.cx) + py = (lat, lon) => fit.cssHeight / 2 - fit.s * (f.y(lat, lon) - fit.cy) + seam = { lonCenter: f.lonCenter, visible: f.visible } +} + +const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() + +function draw() { + ctx.clearRect(0, 0, fit.cssWidth, fit.cssHeight) + + // The whole demo is this one call. Only the arithmetic behind px/py/seam + // changed when the menu did. + limn(ctx, coast, px, py, { + color: css('--line'), + alpha: 0.9, + width: 1, + lonCenter: seam.lonCenter, + visible: seam.visible, + }) + + const bx = px(boat.lon, boat.lat) + const by = py(boat.lat, boat.lon) + ctx.save() + ctx.strokeStyle = css('--mark') + ctx.globalAlpha = 0.9 + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.arc(bx, by, 14, 0, 2 * Math.PI) + ctx.stroke() + ctx.font = '18px system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('⛵', bx, by - 1) + ctx.restore() + + const ns = boat.lat >= 0 ? 'N' : 'S' + const ew = boat.lon >= 0 ? 'E' : 'W' + whereEl.textContent = `⛵ ${Math.abs(boat.lat).toFixed(1)}°${ns} ${Math.abs(boat.lon).toFixed(1)}°${ew}` +} + +let queued = false +function schedule() { + if (queued) return + queued = true + requestAnimationFrame(() => { + queued = false + reframe() + draw() + }) +} + +function dedent(source) { + const lines = source.split('\n') + const indents = lines.slice(1).filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length) + const cut = Math.min(...indents) + return [lines[0], ...lines.slice(1).map((l) => l.slice(cut))].join('\n') +} + +function showMath() { + noteEl.textContent = `${spec.family} — ${spec.note}` + mathEl.textContent = dedent(spec.make.toString()) +} + +menu.addEventListener('change', () => { + spec = projections.find((p) => p.id === menu.value) + if (spec.fitLat) boat.lat = Math.max(-spec.fitLat, Math.min(spec.fitLat, boat.lat)) + showMath() + refit() + schedule() +}) + +// Only the boat drags; there is deliberately no pan and no zoom, so grabbing +// it is forgiving instead of precise. +let dragging = null +canvas.addEventListener('pointerdown', (event) => { + const rect = canvas.getBoundingClientRect() + const dx = event.clientX - rect.left - px(boat.lon, boat.lat) + const dy = event.clientY - rect.top - py(boat.lat, boat.lon) + if (Math.hypot(dx, dy) > 36) return + dragging = { x: event.clientX, y: event.clientY, polarity: 1 } + canvas.setPointerCapture(event.pointerId) + canvas.style.cursor = 'grabbing' +}) +canvas.addEventListener('pointermove', (event) => { + if (!dragging) return + dragging.polarity = nudge(event.clientX - dragging.x, event.clientY - dragging.y, dragging.polarity) + dragging.x = event.clientX + dragging.y = event.clientY + schedule() +}) +for (const done of ['pointerup', 'pointercancel']) { + canvas.addEventListener(done, () => { + dragging = null + canvas.style.cursor = '' + }) +} + +canvas.setAttribute('tabindex', '0') +canvas.addEventListener('keydown', (event) => { + const step = { ArrowLeft: [-12, 0], ArrowRight: [12, 0], ArrowUp: [0, -12], ArrowDown: [0, 12] }[event.key] + if (!step) return + event.preventDefault() + nudge(step[0], step[1], 1) + schedule() +}) + +window.addEventListener('resize', () => { + refit() + schedule() +}) +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', schedule) + +showMath() +refit() +reframe() +draw() diff --git a/demo/gallery.svg b/demo/gallery.svg new file mode 100644 index 0000000..51ae238 --- /dev/null +++ b/demo/gallery.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..2860daa --- /dev/null +++ b/demo/index.html @@ -0,0 +1,126 @@ + + + + + +One map, fourteen projections — coast-wright + + + + + + + + + + + +
+

One map, fourteen projections

+

The coastline never changes and neither does the drawing + call. Pick a projection; the few lines that did change are printed under + the map. Drag the boat — across the date line, over the pole.

+ + + + +
+ +
+
+ drag the boat, or focus the map and use arrow keys + +
+ +

+
+

…and every one of them is drawn by the same call: + limn(ctx, rings(coastline), x, y, { lonCenter, visible }) — + coast-wright has + no projection of its own.

+ +

Coastline: Natural Earth 1:50m as a + portolano, 41 KB, + from the coastlines + package. Feedback → + the issue + this page closes.

+ +
+ + + diff --git a/demo/projections.js b/demo/projections.js new file mode 100644 index 0000000..718efd2 --- /dev/null +++ b/demo/projections.js @@ -0,0 +1,243 @@ +// The menu. Every entry is two functions and sometimes a predicate; none of +// them get drawing code of their own. The page shows `make` verbatim, so each +// body is written to be read -- textbook symbols, one formula per line. +// +// The contract with the page (not with coast-wright, which sees none of this): +// `make(φ0)` closes over the centre latitude and returns `{ X, Y, visible }` +// working in radians, with λ already measured from the centre longitude and +// wrapped to ±π. X grows east, Y grows north; the page fits the result to the +// canvas. `visible` is where a projection admits what it cannot draw -- +// orthographic hides the far hemisphere, azimuthal equidistant masks the +// antipode its arithmetic blows up on. That predicate, passed through to +// `limn`, is the API this demo exists to force; the wrap seam it does not +// replace is limn's own. + +const { sin, cos, tan, atan, asin, acos, log, abs, PI } = Math + +// The three that forced the interface, first. + +export const hard = [ + { + id: 'orthographic', + name: 'Orthographic', + family: 'azimuthal', + note: 'The globe from far away. Half the world is behind it.', + make(φ0) { + const X = (λ, φ) => cos(φ) * sin(λ) + const Y = (λ, φ) => cos(φ0) * sin(φ) - sin(φ0) * cos(φ) * cos(λ) + // Behind the globe: the far hemisphere projects onto the same disc + // as the near one, so it must not be drawn at all. + const visible = (λ, φ) => sin(φ0) * sin(φ) + cos(φ0) * cos(φ) * cos(λ) > 0 + return { X, Y, visible } + }, + }, + { + id: 'azimuthal-equidistant', + name: 'Azimuthal equidistant', + family: 'azimuthal', + note: 'Distance and bearing from the centre are true. The whole world, except the one point behind you.', + make(φ0) { + // c is angular distance from the centre; every direction preserves it. + const cosc = (λ, φ) => sin(φ0) * sin(φ) + cos(φ0) * cos(φ) * cos(λ) + const c = (λ, φ) => acos(Math.max(-1, Math.min(1, cosc(λ, φ)))) + const k = (λ, φ) => (c(λ, φ) < 1e-9 ? 1 : c(λ, φ) / sin(c(λ, φ))) + const X = (λ, φ) => k(λ, φ) * cos(φ) * sin(λ) + const Y = (λ, φ) => k(λ, φ) * (cos(φ0) * sin(φ) - sin(φ0) * cos(φ) * cos(λ)) + // The antipode maps to the whole rim at once -- k → ∞ -- and coastline + // near it smears around the edge. Mask a small cap rather than draw it. + const visible = (λ, φ) => c(λ, φ) < PI - 0.2 + return { X, Y, visible } + }, + }, + { + id: 'mollweide', + name: 'Mollweide', + family: 'pseudocylindrical', + note: 'Equal-area oval. θ has no closed form; Newton finds it in a few steps.', + make() { + const θ = (φ) => { + if (abs(φ) > PI / 2 - 1e-9) return φ + let t = φ + for (let i = 0; i < 8; i++) { + t -= (2 * t + sin(2 * t) - PI * sin(φ)) / (2 + 2 * cos(2 * t)) + } + return t + } + const X = (λ, φ) => ((2 * Math.SQRT2) / PI) * λ * cos(θ(φ)) + const Y = (λ, φ) => Math.SQRT2 * sin(θ(φ)) + return { X, Y } + }, + }, +] + +// The cylindricals: longitude and latitude project independently, which is +// why they all worked before limn learnt to pass both coordinates. The +// equal-area trio differ only in one number -- the standard parallel. + +const cylindricals = [ + { + id: 'plate-carree', + name: 'Plate carrée', + family: 'cylindrical', + note: 'Longitude across, latitude up, nothing else. The oldest projection still in daily use.', + make() { + const X = (λ, φ) => λ + const Y = (λ, φ) => φ + return { X, Y } + }, + }, + { + id: 'mercator', + name: 'Mercator', + family: 'cylindrical', + note: 'A straight line is a constant compass course. The price: y → ∞ at the poles, so no chart can reach one.', + fitLat: 85, + make() { + const X = (λ, φ) => λ + const Y = (λ, φ) => log(tan(PI / 4 + φ / 2)) + return { X, Y } + }, + }, + { + id: 'miller', + name: 'Miller', + family: 'cylindrical', + note: 'Mercator with the latitude scaled back so the poles fit on the page.', + make() { + const X = (λ, φ) => λ + const Y = (λ, φ) => 1.25 * log(tan(PI / 4 + 0.4 * φ)) + return { X, Y } + }, + }, + { + id: 'braun', + name: 'Braun stereographic', + family: 'cylindrical', + note: 'Projected from the equator onto a tangent cylinder.', + make() { + const X = (λ, φ) => λ + const Y = (λ, φ) => 2 * tan(φ / 2) + return { X, Y } + }, + }, + { + id: 'lambert-equal-area', + name: 'Lambert cylindrical equal-area', + family: 'cylindrical', + note: 'Every square kilometre gets equal paper. Standard parallel: the equator.', + make() { + const φs = 0 + const X = (λ, φ) => λ * cos(φs) + const Y = (λ, φ) => sin(φ) / cos(φs) + return { X, Y } + }, + }, + { + id: 'behrmann', + name: 'Behrmann', + family: 'cylindrical', + note: 'Lambert with the standard parallel moved to 30°. Same arithmetic, one number changed.', + make() { + const φs = PI / 6 + const X = (λ, φ) => λ * cos(φs) + const Y = (λ, φ) => sin(φ) / cos(φs) + return { X, Y } + }, + }, + { + id: 'gall-peters', + name: 'Gall–Peters', + family: 'cylindrical', + note: 'The same again at 45°, famous mostly for the argument about it.', + make() { + const φs = PI / 4 + const X = (λ, φ) => λ * cos(φs) + const Y = (λ, φ) => sin(φ) / cos(φs) + return { X, Y } + }, + }, +] + +const pseudocylindricals = [ + { + id: 'sinusoidal', + name: 'Sinusoidal', + family: 'pseudocylindrical', + note: 'Equal-area, and the parallels keep their true length.', + make() { + const X = (λ, φ) => λ * cos(φ) + const Y = (λ, φ) => φ + return { X, Y } + }, + }, + { + id: 'winkel-tripel', + name: 'Winkel tripel', + family: 'pseudocylindrical', + note: 'The average of two projections. National Geographic’s pick for the world since 1998.', + make() { + const φ1 = acos(2 / PI) + const sinc = (t) => (t < 1e-9 ? 1 : sin(t) / t) + const α = (λ, φ) => acos(cos(φ) * cos(λ / 2)) + const X = (λ, φ) => (λ * cos(φ1) + (2 * cos(φ) * sin(λ / 2)) / sinc(α(λ, φ))) / 2 + const Y = (λ, φ) => (φ + sin(φ) / sinc(α(λ, φ))) / 2 + return { X, Y } + }, + }, + { + id: 'kavrayskiy', + name: 'Kavrayskiy VII', + family: 'pseudocylindrical', + note: 'A compromise the Soviets liked and the West rediscovered.', + make() { + const X = (λ, φ) => ((3 * λ) / 2) * Math.sqrt(1 / 3 - (φ / PI) ** 2) + const Y = (λ, φ) => φ + return { X, Y } + }, + }, +] + +const azimuthals = [ + { + id: 'stereographic', + name: 'Stereographic', + family: 'azimuthal', + note: 'Projected from the far side of the sphere; angles survive. The far hemisphere flies off the page, so it is cut at 120° out.', + make(φ0) { + const k = (λ, φ) => 2 / (1 + sin(φ0) * sin(φ) + cos(φ0) * cos(φ) * cos(λ)) + const X = (λ, φ) => k(λ, φ) * cos(φ) * sin(λ) + const Y = (λ, φ) => k(λ, φ) * (cos(φ0) * sin(φ) - sin(φ0) * cos(φ) * cos(λ)) + const visible = (λ, φ) => sin(φ0) * sin(φ) + cos(φ0) * cos(φ) * cos(λ) > cos((2 * PI) / 3) + return { X, Y, visible } + }, + }, +] + +const [orthographic, azimuthalEquidistant, mollweide] = hard + +export const projections = [ + ...cylindricals, + ...pseudocylindricals, + mollweide, + orthographic, + azimuthalEquidistant, + ...azimuthals, +] + +const D = Math.PI / 180 +const wrapped = (d) => (d > 180 ? d - 360 : d < -180 ? d + 360 : d) + +/** + * A projection centred on (lon0, lat0), shaped for `limn`: degrees in, + * map units out, Y growing north. The page scales the result to pixels; + * tests use it as is. + */ +export function frame(spec, lon0, lat0) { + const { X, Y, visible } = spec.make(lat0 * D) + return { + x: (lon, lat) => X(wrapped(lon - lon0) * D, lat * D), + y: (lat, lon) => Y(wrapped(lon - lon0) * D, lat * D), + visible: visible && ((lon, lat) => visible(wrapped(lon - lon0) * D, lat * D)), + lonCenter: lon0, + } +} diff --git a/lib/limn.js b/lib/limn.js index 9c8c413..32a4b98 100644 --- a/lib/limn.js +++ b/lib/limn.js @@ -7,17 +7,36 @@ * `x` and `y` are the caller's lon/lat-to-pixel functions, so this makes no * assumption about the window, the centre, or the scale -- one caller may be * drawing a band around a vessel and another the whole planet, and neither has - * to tell this function which. + * to tell this function which. Both receive the other coordinate as a second + * argument -- `x(lon, lat)` and `y(lat, lon)` -- because outside the + * cylindrical family neither output is computable from one coordinate alone. + * A projection that never looks at the second argument keeps working as it + * always has. + * + * Two different rules lift the pen, and neither replaces the other: * * Segments spanning more than half the world are dropped rather than drawn. A * ring crossing the antimeridian holds two points a tenth of a degree apart on * the ground and 360 apart in the numbers; so does a ring passing behind a * window centred anywhere but Greenwich. Either one, joined, lays a line * across the entire map -- so the seam is tested against `lonCenter`, the - * longitude the caller's own projection measures from. + * longitude the caller's own projection measures from. For a projection that + * does not wrap, the rule's only false positives are near-antipodal jumps + * whose endpoints project close together anyway; the cost is a sub-pixel gap, + * where the cost of drawing a wrapped segment is a line across the map. + * + * `visible` is for the projections whose seam is not a wrap at all: it is + * asked before each point, and a point it refuses is neither drawn nor even + * projected -- the pen lifts, and the ring resumes at the next visible point. + * Orthographic hides the far hemisphere, which otherwise lands mirrored + * inside the same disc; azimuthal equidistant masks a cap around the + * antipode, where its arithmetic divides by zero and the coastline smears + * around the rim. Not projecting refused points is part of the contract: + * inside the mask is exactly where `x` and `y` cannot be trusted to return + * numbers. */ export function limn(ctx, rings, x, y, options = {}) { - const { color, alpha = 0.45, width = 1, lonCenter = 0 } = options + const { color, alpha = 0.45, width = 1, lonCenter = 0, visible } = options const fromCentre = (lon) => { let delta = lon - lonCenter if (delta > 180) delta -= 360 @@ -38,8 +57,12 @@ export function limn(ctx, rings, x, y, options = {}) { const delta = fromCentre(lon) if (i > 0 && Math.abs(delta - previous) > 180) pendown = false previous = delta - if (pendown) ctx.lineTo(x(lon), y(lat)) - else ctx.moveTo(x(lon), y(lat)) + if (visible && !visible(lon, lat)) { + pendown = false + continue + } + if (pendown) ctx.lineTo(x(lon, lat), y(lat, lon)) + else ctx.moveTo(x(lon, lat), y(lat, lon)) pendown = true } ctx.stroke() diff --git a/package.json b/package.json index 478afba..8b4c799 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coast-wright", - "version": "0.0.1-alpha.0", + "version": "0.0.1-alpha.1", "description": "Decode and draw portolani: seam-aware, projection-agnostic coastline rendering to a canvas, with no dependencies", "type": "module", "main": "lib/index.js", @@ -9,13 +9,38 @@ "./decode": "./lib/decode.js", "./package.json": "./package.json" }, - "scripts": { "test": "node --test" }, - "devDependencies": { "coastlines": "^0.0.1-alpha.0" }, - "peerDependenciesMeta": { "coastlines": { "optional": true } }, - "engines": { "node": ">=20" }, - "keywords": ["coastline", "canvas", "map", "polyline", "portolano", "marine", "signalk"], + "scripts": { + "test": "node --test", + "build:demo": "node scripts/build-demo.mjs" + }, + "devDependencies": { + "coastlines": "^0.0.1-alpha.0" + }, + "peerDependenciesMeta": { + "coastlines": { + "optional": true + } + }, + "engines": { + "node": ">=20.1.0" + }, + "keywords": [ + "coastline", + "canvas", + "map", + "polyline", + "portolano", + "marine", + "signalk" + ], "license": "MIT", "author": "Mark Brannan", - "repository": { "type": "git", "url": "git+https://github.com/mark-brannan/coast-wright.git" }, - "files": ["lib", "LICENSE"] + "repository": { + "type": "git", + "url": "git+https://github.com/mark-brannan/coast-wright.git" + }, + "files": [ + "lib", + "LICENSE" + ] } diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs new file mode 100644 index 0000000..3476d40 --- /dev/null +++ b/scripts/build-demo.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Assembles the GitHub Pages demo into _site/: the static page and its +// vendored coastline from demo/, and the unmodified published library under +// _site/lib/ so a plain static host can serve the same files npm ships. +// Local, offline, no dependencies -- preview with any static server, e.g. +// `python3 -m http.server -d _site`. +// +// The page leans on the library's own smallness, so the build fails rather +// than ship a site heavier than the budget. + +import { cp, mkdir, readdir, rm, stat } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const site = path.join(root, '_site') +const BUDGET = 160_000 + +await rm(site, { recursive: true, force: true }) +await mkdir(path.join(site, 'lib'), { recursive: true }) +await cp(path.join(root, 'demo'), site, { recursive: true }) +await cp(path.join(root, 'lib'), path.join(site, 'lib'), { recursive: true }) + +let total = 0 +for (const entry of await readdir(site, { recursive: true })) { + const info = await stat(path.join(site, entry)) + if (info.isFile()) total += info.size +} +if (total > BUDGET) { + console.error(`_site is ${total} bytes, over the ${BUDGET} budget`) + process.exit(1) +} +console.log(`_site assembled: ${total} bytes of ${BUDGET} budget`) diff --git a/test/limn.test.mjs b/test/limn.test.mjs index 8759c73..d2753ec 100644 --- a/test/limn.test.mjs +++ b/test/limn.test.mjs @@ -114,3 +114,80 @@ test('limn projects through the caller functions and nothing else', () => { limn(ctx, [[[0, 0], [10, 0]]], (lon) => lon * 2, (lat) => lat * 3 + 1, {}) assert.deepEqual(ctx.segments, [[[0, 1], [20, 1]]]) }) + +// Outside the cylindrical family neither output is computable from one +// coordinate alone, so each function gets the other coordinate too -- +// x(lon, lat) and y(lat, lon), first arguments unchanged so a one-argument +// caller never notices. +test('x and y each receive the other coordinate second', () => { + const calls = [] + limn( + recordingContext(), + [[[10, 20], [11, 21]]], + (lon, lat) => (calls.push(['x', lon, lat]), lon), + (lat, lon) => (calls.push(['y', lat, lon]), lat), + {} + ) + assert.deepEqual(calls, [ + ['x', 10, 20], + ['y', 20, 10], + ['x', 11, 21], + ['y', 21, 11], + ]) +}) + +// The generalised seam: a projection that does not wrap says which points it +// cannot draw, and a refused point lifts the pen -- the ring must resume at +// the next visible point, never bridge the gap. +test('a refused point lifts the pen rather than bridging the gap', () => { + const ring = [[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]] + const ctx = recordingContext() + limn(ctx, [ring], (lon) => lon, (lat) => lat, { visible: (lon) => lon !== 2 }) + assert.deepEqual(ctx.segments, [ + [[0, 0], [1, 0]], + [[3, 0], [4, 0]], + ]) +}) + +// Refusal happens before projection. Inside an azimuthal projection's +// antipode mask is exactly where x and y divide by zero, so asking them +// there -- even for a moveTo that never strokes -- is the bug. +test('a refused point is never projected', () => { + const asked = [] + limn( + recordingContext(), + [[[0, 0], [2, 0], [4, 0]]], + (lon) => (asked.push(['x', lon]), lon), + (lat) => (asked.push(['y', lat]), lat), + { visible: (lon) => lon !== 2 } + ) + assert.deepEqual(asked, [ + ['x', 0], ['y', 0], + ['x', 4], ['y', 0], + ]) +}) + +// Orthographic-shaped, with real coastline and a centre in the Pacific: the +// far hemisphere projects mirrored into the same disc as the near one, so +// nothing on it may reach the projection at all. The wrap seam keeps +// applying alongside -- both rules run, neither replaces the other. +test('a hemisphere mask keeps the far side out of the projection entirely', () => { + const lonCenter = 178 + const lat0 = -17 * (Math.PI / 180) + const nearSide = (lon, lat) => { + const λ = ((((lon - lonCenter + 180) % 360) + 360) % 360) - 180 + const φ = lat * (Math.PI / 180) + return Math.sin(lat0) * Math.sin(φ) + Math.cos(lat0) * Math.cos(φ) * Math.cos(λ * (Math.PI / 180)) > 0 + } + const { x, y } = project(lonCenter) + const guarded = (project) => (a, b) => { + assert.ok(nearSide(...(project === x ? [a, b] : [b, a])), `far-side point projected: ${a},${b}`) + return project(a) + } + const ctx = recordingContext() + limn(ctx, rings(coastline), guarded(x), guarded(y), { lonCenter, visible: nearSide }) + assert.ok(ctx.segments.length > 500, `only ${ctx.segments.length} segments drawn`) + for (const [from, to] of ctx.segments) { + assert.ok(Math.abs(to[0] - from[0]) < width / 2, 'seam drawn despite the mask') + } +}) diff --git a/test/projections.test.mjs b/test/projections.test.mjs new file mode 100644 index 0000000..657c8a5 --- /dev/null +++ b/test/projections.test.mjs @@ -0,0 +1,89 @@ +// The gallery's contract, pinned: every projection in the menu is drawn by +// the published limn -- same call, no special-case drawing code -- and what +// comes out is a map, not a seam. Chords are measured against the drawn +// extent because a wrap or a rim smear shows up as a stroke comparable to +// the whole map, while honest distortion of the fixture's longest (~16 +// degree) segments stays well under that. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { createHash } from 'node:crypto' +import { rings, limn } from '../lib/index.js' +import { projections, frame } from '../demo/projections.js' + +const require = createRequire(import.meta.url) +const coastline = require('./fixtures/coastline-110m.json') +const coast = rings(coastline) + +// The page's own coastline is vendored from the coastlines package like the +// test fixtures are, and pinned the same way: a stale or edited copy fails +// instead of quietly shipping. +test('the vendored demo coastline is the file it claims to be', () => { + const shipped = require('../demo/coastline-50m.json') + const digest = + 'sha256:' + createHash('sha256').update(JSON.stringify(shipped.geometry)).digest('hex') + assert.equal(digest, shipped.digest, shipped.provenance.source.id) +}) + +test('the menu is a dozen strong and not all cylinders', () => { + assert.ok(projections.length >= 12, `${projections.length} projections`) + assert.equal(new Set(projections.map((p) => p.id)).size, projections.length) + const bent = projections.filter((p) => p.family !== 'cylindrical') + assert.ok(bent.length >= 3, `only ${bent.length} non-cylindrical`) +}) + +// Greenwich flatters everything; Fiji finds wrap bugs, the Antarctic centre +// puts azimuthal equidistant's antipode in the Canadian Arctic, where there +// is plenty of coastline to smear around the rim. +const centres = [ + [0, 0], + [178, -17], + [25, -78], +] + +for (const spec of projections) { + for (const [lon0, lat0] of centres) { + test(`${spec.id} draws a map through limn, centred ${lon0},${lat0}`, () => { + const { x, y, visible, lonCenter } = frame(spec, lon0, lat0) + let at = null + const segments = [] + const ctx = { + save() {}, + restore() {}, + beginPath() { + at = null + }, + stroke() {}, + moveTo(px, py) { + assert.ok(Number.isFinite(px) && Number.isFinite(py), `non-finite point ${px},${py}`) + at = [px, py] + }, + lineTo(px, py) { + assert.ok(Number.isFinite(px) && Number.isFinite(py), `non-finite point ${px},${py}`) + if (at) segments.push([at, [px, py]]) + at = [px, py] + }, + } + limn(ctx, coast, x, y, { lonCenter, visible }) + + assert.ok(segments.length > 500, `only ${segments.length} segments drawn`) + let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity + for (const [a, b] of segments) { + for (const [px, py] of [a, b]) { + if (px < minx) minx = px + if (px > maxx) maxx = px + if (py < miny) miny = py + if (py > maxy) maxy = py + } + } + const diagonal = Math.hypot(maxx - minx, maxy - miny) + for (const [a, b] of segments) { + const chord = Math.hypot(b[0] - a[0], b[1] - a[1]) + assert.ok( + chord < diagonal / 5, + `a stroke ${((chord / diagonal) * 100).toFixed(0)}% of the map long -- a seam, not a coastline` + ) + } + }) + } +}