2023-03-08 16:45

[Angular] 簡單製作多國語系

我想要用一種簡單而且直覺的方式製作多國語系,想像的用法如下:

user-list.component.html

<h2>{{lang['T_編輯使用者']}}</h2>

<div>
    <p *ngIf="lang.id == 'en'">
    Complicated English description...
    </p>
    <p *ngIf="lang.id == 'tw'">
    複雜的中文描述...
    </p>
</div>

<button type="submit">{{lang['B_儲存']}}</button>

<select [(ngModel)]="lang.id" (change)="lang.change()">
    <option *ngFor="let x of lang.options | keyvalue" [value]="x.key">{{x.value}}</option>
</select>

我希望能使用中文變數,有熟悉的文字可以讓程式看起來親切一點,對於複雜的文字區段也保留彈性,簡單的切換方式,以及狀態保留,讓 browser 紀錄選擇的語系。

user-list.component.ts

import { Component, OnInit } from '@angular/core';
import { AppLang } from 'app/app.lang';

@Component({
    selector: 'app-user-list',
    templateUrl: './user-list.component.html'
})
export class UserListComponent implements OnInit {

    /** 狀態選項 */
    statusItems = {
        'y': this.lang.E_啟用,
        'n': this.lang.E_停用,
    };

    constructor(
        public lang: AppLang
    ) { }

    ngOnInit() {
        let msg = String(this.lang.M_請填寫_0_的刻度值).format(3);
    }
}

已經想好使用情境了,接著來就是完成實作了!

app.lang.ts

import { Injectable } from '@angular/core';

/** 多國語言 */
@Injectable()
export class AppLang {

    constructor() {
        let _: any = {}; /* 語系字典 */
        let self: any = this;
        let names: string[] = Object.keys(this).filter(n => !'id,options,change'.includes(n));

        /* 重組語系字典 */
        Object.keys(this.options).forEach(id => {
            _[id] = {};
            names.forEach(name => _[id][name] = self[name][id]);
        });

        /* 複寫 change */
        this.change = function () {
            if (!_[this.id]) { this.id = 'tw'; }

            Object.assign(this, _[this.id]);
            localStorage['langId'] = this.id;
        }

        this.change ();
    }


    id: string = localStorage['langId'];

    options: any = {
        tw: '中文',
        en: 'English',
    };

    /** 變更語言 */
    change() { }



    /*=[ Title ]=============================================*/

    T_編輯使用者: any = {
        tw: '編輯使用者',
        en: 'Edit User',
    };
    //...

    /*=[ Field ]=============================================*/

    F_名稱: any = {
        tw: '名稱',
        en: 'Name',
    };
    //...

    /*=[ Button ]=============================================*/

    B_儲存: any = {
        tw: '儲存',
        en: 'Save',
    };
    //...


    /*=[ Enum ]=============================================*/

    E_啟用: any = {
        tw: '啟用',
        en: 'Enable',
    };
    E_停用: any = {
        tw: '停用',
        en: 'Disable',
    };
    //...


    /*=[ Message ]=============================================*/

    M_請填寫_0_的刻度值: any = {
        tw: '請填寫 {0} 的刻度值',
        en: 'Please fill in the scale value of {0}.',
    };
    //...

}

app.module.ts 配置

//...
import { AppLang } from 'app/app.lang';

@NgModule({
    declarations: [
        //...
    ],
    imports: [
        //...
    ],
    providers: [
        //...
        AppLang
    ],
    bootstrap: [AppComponent]
})
export class AppModule { }
2023-03-08 14:21

[Angular] 將 select, radio 改成一般等於 (==)

Angular 預設的 select, radio 比對是用全等於(===),這對用 string 的選項來說沒甚麼問題,但如果是數字選項就會出現型態不一致的問題,就必須型態轉換,這有點煩人,所以想要改成一般等於(==),在 Angular 的原始碼找到可以複寫判斷句的地方。

app.module.ts

import { RadioControlValueAccessor, SelectControlValueAccessor } from '@angular/forms';

/* 將 SelectControlValueAccessor 的全等於改成一般等於 */
SelectControlValueAccessor.prototype.compareWith = function (a, b) {
    return a == b;
};

/* 將 RadioControlValueAccessor 的全等於改成一般等於 */
RadioControlValueAccessor.prototype.writeValue = function (value: any) {
    let self: any = this;
    self._state = value == this.value;
    self._renderer.setProperty(self._elementRef.nativeElement, 'checked', self._state);
};
2023-03-07 16:55

[ThreeJS] 單次動畫

/* 單次動畫 */
function animateOnce(mixer, prop, dur, values) {
    /* 停止 & 清除 先前的動畫 */
    mixer.stopAllAction();
    mixer.uncacheRoot(mixer.getRoot());

    /* 增加動畫片段 */
    const track = new THREE.KeyframeTrack(prop, [0, dur], values);
    const clip = new THREE.AnimationClip('move', dur, [track]);
    const action = mixer.clipAction(clip);
    action.clampWhenFinished = true;
    action.setLoop(THREE.LoopOnce);

    /* Promise 來處理完成事件,這樣就可以用 await */
    return new Promise((resolve) => {
        const finished = function () {
            mixer.removeEventListener('finished', finished);
            resolve();
        };
        mixer.addEventListener('finished', finished);

        action.play(); /* 播放動畫 */
    });
}

想要能像 SVG 一樣簡單的控制模型的移動,ThreeJS 並沒有直接提供我想要的模式,試了很久終於成功了。

mixer 中的 action 是不斷添加的,這會混亂播放動畫,所以在播放新的動畫前必須[停止]且[清除]先前的動畫。

為了可以簡單串接不同模型的動畫,所以就想用 await 來達成,所以加上 Promise 的包裝。

完整程式

/* 初始化渲染器 */
const renderer = new THREE.WebGLRenderer({ antialias: true });
document.getElementById('Container').appendChild(renderer.domElement);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);

/* 初始化場景 */
const scene = new THREE.Scene();
scene.background = new THREE.Color('#000'); /* 背景顏色 */
scene.add(new THREE.AmbientLight('#FFF', 0.5)); /* 加入環境光 */
scene.add(new THREE.AxesHelper(50)); /* 3D 軸標示 */

/* 初始化鏡頭 */
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 300);
camera.position.set(0, 4, 12);

/* 初始化軌道控制,鏡頭的移動 */
const orbitControls = new THREE.OrbitControls(camera, renderer.domElement);
orbitControls.update();


/* 動畫刷新器 */
const mixers = [];

function newMixer(target) {
    const mixer = new THREE.AnimationMixer(target);
    mixers.push(mixer);
    return mixer;
}

/* 動畫計時器 */
const clock = new THREE.Clock();

/* 渲染週期 */
function renderCycle() {
    const delta = clock.getDelta();
    mixers.forEach(x => x.update(delta));

    renderer.render(scene, camera);
    requestAnimationFrame(renderCycle);
}
renderCycle();



/*---------------------------------------------------------------*/

/* 單次動畫 */
function animateOnce(mixer, prop, dur, values) {
    /* 停止 & 清除 先前的動畫 */
    mixer.stopAllAction();
    mixer.uncacheRoot(mixer.getRoot());

    /* 增加動畫片段 */
    const track = new THREE.KeyframeTrack(prop, [0, dur], values);
    const clip = new THREE.AnimationClip('move', dur, [track]);
    const action = mixer.clipAction(clip);
    action.clampWhenFinished = true;
    action.setLoop(THREE.LoopOnce);

    /* Promise 來處理完成事件,這樣就可以用 await */
    return new Promise((resolve) => {
        const finished = function () {
            mixer.removeEventListener('finished', finished);
            resolve();
        };
        mixer.addEventListener('finished', finished);

        action.play(); /* 播放動畫 */
    });
}


const cubeA = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial( {color: '#0F0'} )
);
const mixerA = newMixer(cubeA);
scene.add(cubeA);


const cubeB = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial( {color: '#00F'} )
);
const mixerB = newMixer(cubeB);
scene.add(cubeB);


async function run() {
    await animateOnce(mixerA, '.position[x]', 3, [0, 3]);
    await animateOnce(mixerB, '.position[y]', 3, [0, 2]);
    await animateOnce(mixerA, '.position[x]', 3, [3, 0]);
    await animateOnce(mixerB, '.position[y]', 3, [2, 0]);
}

run();
2023-03-07 16:06

[ThreeJS] 用 SVG 貼圖顯示中文

HTML

<div id="Container"></div>

<div id="LabelTpl" style="display:none;">
    <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 30 30">
        <text x="15" y="15" fill="#fff" text-anchor="middle"></text>
    </svg>
</div>

<script src="../Scripts/three.145/three.min.js"></script>
<script src="../Scripts/three.145/controls/OrbitControls.js"></script>
<script src="../Scripts/three.145/loaders/GLTFLoader.js"></script>
<script src="main.js"></script>

main.js

/* 初始化渲染器 */
const renderer = new THREE.WebGLRenderer({ antialias: true });
document.getElementById('Container').appendChild(renderer.domElement);

renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);


/* 初始化場景 */
const scene = new THREE.Scene();
scene.background = new THREE.Color('#000'); /* 背景顏色 */
scene.add(new THREE.AmbientLight('#FFF', 0.5)); /* 加入環境光 */
scene.add(new THREE.AxesHelper(50)); /* 3D 軸標示 */

/* 初始化鏡頭 */
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 300);
camera.position.set(0, 4, 12);


/* 初始化軌道控制,鏡頭的移動 */
const orbitControls = new THREE.OrbitControls(camera, renderer.domElement);
orbitControls.update();


/* 渲染週期 */
function renderCycle() {
    renderer.render(scene, camera);
    requestAnimationFrame(renderCycle);
}
renderCycle();



/*---------------------------------------------------------------*/

function svgBase64(svg) {
    svg = svg.replace(/\s+</g, '<').replace(/>\s+/g, '>');
    return 'data:image/svg+xml,' + encodeURIComponent(svg);
}


const textureLoader = new THREE.TextureLoader();
const $labelTpl = document.querySelector('#LabelTpl');
const $labelText = document.querySelector('#LabelTpl text');


/* 環線 */
function addCircle(label,  z) {
    $labelText.innerHTML = label;
    let imageData = svgBase64($labelTpl.innerHTML);

    const particles = new THREE.Points(
        new THREE.EdgesGeometry(new THREE.CircleGeometry(10, 6)),
        new THREE.PointsMaterial({
            map: textureLoader.load(imageData),
            color: '#FFF',
            size: 4,
            depthWrite: false,
            transparent: true,
        })
    );
    particles.position.z = z;
    scene.add(particles);


    const circle = new THREE.LineSegments(
        new THREE.EdgesGeometry(new THREE.CircleGeometry(10, 60)),
        new THREE.LineBasicMaterial({ color: '#FFF' })
    );
    circle.position.z = z;
    scene.add(circle);
}

addCircle('冬至', 4);
addCircle('夏至', -4);
2023-03-07 13:01

[ThreeJS] 解決陰影造成的條紋

 會出現條紋現象是因為雙面材質,只要設置為單面材質就可以解決。

/* 載入模型 */
new THREE.GLTFLoader().load('Model.glb', function (gltf) {
    const model = gltf.scene;

    model.traverse(obj => {
        if (!obj.isMesh) { return; }

        obj.frustumCulled = false;
        obj.castShadow = true;
        obj.receiveShadow = true;

        /* 解決陰影造成的條紋 */
        obj.material.side = THREE.FrontSide;
        obj.material.shadowSide = THREE.FrontSide;
    });

    scene.add(model);
});
2023-03-07 12:49

[ThreeJS] DragControls 拖移出現異常的原因

 需要進行拖移的模型其[原點]最好設置在[幾何中心]

因為拖移出現異常,我為了解決這個問題,認真看 DragControls 的原始碼,所有處理拖移的邏輯都相當周全,基本上不應該出現異常現象。

在用 console.log 查看拖移的座標數值時,才發現原來是模型的[原點]設置在[場景中心]造成的。

2023-03-07 11:37

[ThreeJS] 載入壓縮過 GLTF 檔

/* 解壓縮 lib 載入器
 * https://threejs.org/docs/#examples/en/loaders/DRACOLoader
 * https://google.github.io/draco/
 */
const dracoLoader = new THREE.DRACOLoader();

/* 指定解壓器的位置 */
dracoLoader.setDecoderPath('../Scripts/three.145/libs/draco/');


/* GLTF 載入器 */
const gltfLoader = new THREE.GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);

/* 載入模型 */
gltfLoader.load('Model.glb', function (gltf) {
    const model = gltf.scene;
    scene.add(model);
});
2023-03-03 15:08

[ThreeJS] 材質發黑是因為金屬屬性

剛開始接觸 ThreeJS 時,被材質發黑這個問題,困擾很久,一直沒找到問題點,不管怎麼增加 HemisphereLight 都無效,用了四個方向 DirectionalLight 才呈現能接受的效果。

後來才發現問題點是金屬屬性 (metalness),因為金屬有鏡面效果會映像出周圍的景象,然後我又沒有設置 environment map,整個場景 (scene) 都是黑漆漆的,所以材質映像出來也會是黑色的。